Unverified Commit e519549c authored by Lemon-miaow's avatar Lemon-miaow
Browse files

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

parent 42431210
Loading
Loading
Loading
Loading
+129 −0
Changes for panel/src/pages/admin/UsersPage.test.tsx: 129 added lines, 0 removed lines.
Original line number Diff line number Diff line
// @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
Changes for panel/src/pages/admin/UsersPage.tsx: 26 added lines, 4 removed lines.
Original line number Diff line number Diff line
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;