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 { Link, useNavigate } from "react-router-dom";
|
||||||
import { RoleBadge } from "@/components/RoleBadge";
|
import { RoleBadge } from "@/components/RoleBadge";
|
||||||
import { UserStatusBadge } from "@/components/UserStatusBadge";
|
import { UserStatusBadge } from "@/components/UserStatusBadge";
|
||||||
@@ -30,35 +30,57 @@ import { formatAbsolute } from "@/lib/format";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { UserView } from "@/lib/types";
|
import type { UserView } from "@/lib/types";
|
||||||
|
|
||||||
|
const SEARCH_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
export function UsersPage() {
|
export function UsersPage() {
|
||||||
const { t, i18n } = useTranslation("admin");
|
const { t, i18n } = useTranslation("admin");
|
||||||
const locale = i18n.language;
|
const locale = i18n.language;
|
||||||
const navigate = useNavigate();
|
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 [query, setQuery] = useState("");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
const [roleFilter, setRoleFilter] = useState("");
|
const [roleFilter, setRoleFilter] = useState("");
|
||||||
const [disabledFilter, setDisabledFilter] = useState("");
|
const [disabledFilter, setDisabledFilter] = useState("");
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const pageSize = 20;
|
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(
|
const fetchUsers = useCallback(
|
||||||
() =>
|
() =>
|
||||||
api.listUsers({
|
api.listUsers({
|
||||||
query: query || undefined,
|
query: search || undefined,
|
||||||
role: (roleFilter || undefined) as "admin" | "user" | undefined,
|
role: (roleFilter || undefined) as "admin" | "user" | undefined,
|
||||||
disabled: (disabledFilter || undefined) as "true" | "false" | undefined,
|
disabled: (disabledFilter || undefined) as "true" | "false" | undefined,
|
||||||
limit: pageSize,
|
limit: pageSize,
|
||||||
offset: page * pageSize,
|
offset: page * pageSize,
|
||||||
}),
|
}),
|
||||||
[query, roleFilter, disabledFilter, page],
|
[search, roleFilter, disabledFilter, page],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, error, loading, reload } = useAsync(fetchUsers, [fetchUsers], { keepPrevious: true });
|
const { data, error, loading, reload } = useAsync(fetchUsers, [fetchUsers], { keepPrevious: true });
|
||||||
|
|
||||||
const handleSearch = (e: React.FormEvent) => {
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
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);
|
setPage(0);
|
||||||
reload();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const totalPages = data ? Math.max(1, Math.ceil(data.total / pageSize)) : 1;
|
const totalPages = data ? Math.max(1, Math.ceil(data.total / pageSize)) : 1;
|
||||||
|
|||||||
Reference in new issue
Block a user