fix(users): 用户搜索停止输入 300 毫秒后才请求,新搜索回到第一页

This commit is contained in:
Lemon-miaow committed 2026-09-27 06:10:24 +08:00
1 parent 42431210c3
commit e519549c8a
2 files changed
+155 -4

No files matched your search

+129
View File
@@ -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();
});
});
+26 -4
View File
@@ -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();
setPage(0);
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);
};
const totalPages = data ? Math.max(1, Math.ceil(data.total / pageSize)) : 1;