fix(users): 用户搜索停止输入 300 毫秒后才请求,新搜索回到第一页
This commit is contained in:
2 files changed
+155
-4
No files matched your search
@@ -0,0 +1,129 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import i18next from "i18next";
|
||||
import { UsersPage } from "./UsersPage";
|
||||
import type { UserView } from "@/lib/types";
|
||||
|
||||
const calls = vi.hoisted(() => ({ listUsers: vi.fn() }));
|
||||
vi.mock("@/lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/api")>();
|
||||
return { ...actual, api: { ...actual.api, ...calls } };
|
||||
});
|
||||
|
||||
const t = (key: string) => i18next.t(key);
|
||||
|
||||
// 45 users: three pages of 20.
|
||||
function user(i: number): UserView {
|
||||
return {
|
||||
id: `u-${i}`,
|
||||
username: `player${i}`,
|
||||
role: "user",
|
||||
disabled: false,
|
||||
email_verified: true,
|
||||
server_count: 0,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
async function renderPage() {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<UsersPage />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
await settle();
|
||||
}
|
||||
|
||||
/** settle lets the mocked request resolve and the page render it. */
|
||||
async function settle(ms = 0) {
|
||||
await act(() => vi.advanceTimersByTimeAsync(ms));
|
||||
}
|
||||
|
||||
const box = () => screen.getByPlaceholderText(t("admin:users_search_placeholder"));
|
||||
|
||||
/** type puts text into the search box a key at a time, as typing does. (user-event
|
||||
* stalls under fake timers, which the debounce needs.) */
|
||||
function type(text: string) {
|
||||
for (let i = 1; i <= text.length; i++) fireEvent.change(box(), { target: { value: text.slice(0, i) } });
|
||||
}
|
||||
|
||||
const next = () => fireEvent.click(screen.getByRole("button", { name: t("common:pagination_next") }));
|
||||
const press = () => fireEvent.click(screen.getByRole("button", { name: t("admin:users_search_btn") }));
|
||||
const newCalls = (before: number) => calls.listUsers.mock.calls.slice(before);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
calls.listUsers.mockReset();
|
||||
calls.listUsers.mockImplementation(async ({ offset = 0 }: { offset?: number }) => ({
|
||||
users: Array.from({ length: Math.min(20, 45 - offset) }, (_, i) => user(offset + i)),
|
||||
total: 45,
|
||||
}));
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("UsersPage search", () => {
|
||||
it("searches once typing stops, from page one", async () => {
|
||||
await renderPage();
|
||||
next();
|
||||
await settle();
|
||||
expect(calls.listUsers).toHaveBeenLastCalledWith({ limit: 20, offset: 20 });
|
||||
const before = calls.listUsers.mock.calls.length;
|
||||
|
||||
type(" steve ");
|
||||
await settle(299);
|
||||
expect(newCalls(before)).toEqual([]);
|
||||
|
||||
await settle(1);
|
||||
expect(newCalls(before)).toEqual([[{ query: "steve", limit: 20, offset: 0 }]]);
|
||||
});
|
||||
|
||||
it("applies the search at once on Enter, and only once", async () => {
|
||||
await renderPage();
|
||||
const before = calls.listUsers.mock.calls.length;
|
||||
|
||||
type("alex");
|
||||
fireEvent.submit(box());
|
||||
await settle();
|
||||
expect(newCalls(before)).toEqual([[{ query: "alex", limit: 20, offset: 0 }]]);
|
||||
|
||||
await settle(1000);
|
||||
expect(newCalls(before)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("takes an unchanged search on page two back to page one", async () => {
|
||||
await renderPage();
|
||||
next();
|
||||
await settle();
|
||||
const before = calls.listUsers.mock.calls.length;
|
||||
|
||||
press();
|
||||
await settle(1000);
|
||||
|
||||
expect(newCalls(before)).toEqual([[{ limit: 20, offset: 0 }]]);
|
||||
});
|
||||
|
||||
it("refreshes when the search is pressed with nothing changed on page one", async () => {
|
||||
await renderPage();
|
||||
const before = calls.listUsers.mock.calls.length;
|
||||
|
||||
press();
|
||||
await settle();
|
||||
|
||||
expect(newCalls(before)).toEqual([[{ limit: 20, offset: 0 }]]);
|
||||
});
|
||||
|
||||
it("stays on the page someone turned to while the box is untouched", async () => {
|
||||
await renderPage();
|
||||
|
||||
next();
|
||||
await settle(1000);
|
||||
|
||||
expect(calls.listUsers).toHaveBeenLastCalledWith({ limit: 20, offset: 20 });
|
||||
expect(screen.getByText("player20")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { RoleBadge } from "@/components/RoleBadge";
|
||||
import { UserStatusBadge } from "@/components/UserStatusBadge";
|
||||
@@ -30,35 +30,57 @@ import { formatAbsolute } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { UserView } from "@/lib/types";
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
export function UsersPage() {
|
||||
const { t, i18n } = useTranslation("admin");
|
||||
const locale = i18n.language;
|
||||
const navigate = useNavigate();
|
||||
|
||||
// The box searches on the server a moment after typing stops, and a new
|
||||
// search starts from page one: the page someone was on may not exist in the
|
||||
// new results. Enter or the button applies it at once.
|
||||
const [query, setQuery] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [roleFilter, setRoleFilter] = useState("");
|
||||
const [disabledFilter, setDisabledFilter] = useState("");
|
||||
const [page, setPage] = useState(0);
|
||||
const pageSize = 20;
|
||||
|
||||
useEffect(() => {
|
||||
const next = query.trim();
|
||||
if (next === search) return;
|
||||
const timer = setTimeout(() => {
|
||||
setSearch(next);
|
||||
setPage(0);
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [query, search]);
|
||||
|
||||
const fetchUsers = useCallback(
|
||||
() =>
|
||||
api.listUsers({
|
||||
query: query || undefined,
|
||||
query: search || undefined,
|
||||
role: (roleFilter || undefined) as "admin" | "user" | undefined,
|
||||
disabled: (disabledFilter || undefined) as "true" | "false" | undefined,
|
||||
limit: pageSize,
|
||||
offset: page * pageSize,
|
||||
}),
|
||||
[query, roleFilter, disabledFilter, page],
|
||||
[search, roleFilter, disabledFilter, page],
|
||||
);
|
||||
|
||||
const { data, error, loading, reload } = useAsync(fetchUsers, [fetchUsers], { keepPrevious: true });
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const next = query.trim();
|
||||
// Unchanged and on page one: the press is a refresh.
|
||||
if (next === search && page === 0) {
|
||||
reload();
|
||||
return;
|
||||
}
|
||||
setSearch(next);
|
||||
setPage(0);
|
||||
reload();
|
||||
};
|
||||
|
||||
const totalPages = data ? Math.max(1, Math.ceil(data.total / pageSize)) : 1;
|
||||
|
||||
Reference in new issue
Block a user