diff --git a/panel/src/pages/admin/UsersPage.test.tsx b/panel/src/pages/admin/UsersPage.test.tsx new file mode 100644 index 0000000..bcb02d1 --- /dev/null +++ b/panel/src/pages/admin/UsersPage.test.tsx @@ -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(); + 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( + + + , + ); + 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(); + }); +}); diff --git a/panel/src/pages/admin/UsersPage.tsx b/panel/src/pages/admin/UsersPage.tsx index bcab8ab..e1b5941 100644 --- a/panel/src/pages/admin/UsersPage.tsx +++ b/panel/src/pages/admin/UsersPage.tsx @@ -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;