diff --git a/panel/src/pages/MySubmissionsPage.test.tsx b/panel/src/pages/MySubmissionsPage.test.tsx index afd1bbf..c680906 100644 --- a/panel/src/pages/MySubmissionsPage.test.tsx +++ b/panel/src/pages/MySubmissionsPage.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { act, render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; import i18next from "i18next"; @@ -78,6 +78,26 @@ describe("MySubmissionsPage", () => { await vi.waitFor(() => expect(screen.queryByText(/^Page \d+ of/)).toBeNull()); }); + // The clock moves only when the test moves it, so the page is turned before + // the search box's pause has run out however fast the machine is. + it("keeps a page turned to right after the list loads", async () => { + vi.useFakeTimers(); + render( + + + , + ); + await act(() => vi.advanceTimersByTimeAsync(0)); + expect(screen.getByText("Page 1 of 2")).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Next" })); + await act(() => vi.advanceTimersByTimeAsync(0)); + expect(calls.listMySubmissions).toHaveBeenLastCalledWith({ limit: 10, offset: 10 }); + await act(() => vi.advanceTimersByTimeAsync(1_000)); + expect(calls.listMySubmissions).toHaveBeenLastCalledWith({ limit: 10, offset: 10 }); + expect(screen.getByText("Page 2 of 2")).toBeTruthy(); + }); + it("refuses a file over the server's per-upload cap before sending it", async () => { render( diff --git a/panel/src/pages/MySubmissionsPage.tsx b/panel/src/pages/MySubmissionsPage.tsx index 1ea8994..c274cbb 100644 --- a/panel/src/pages/MySubmissionsPage.tsx +++ b/panel/src/pages/MySubmissionsPage.tsx @@ -113,13 +113,17 @@ export function MySubmissionsPage() { const [statusFilter, setStatusFilter] = useState<"all" | SubmissionStatus>("all"); const [page, setPage] = useState(1); const [expandedId, setExpandedId] = useState(null); + // A search already asked (the first render's included) starts no timer, so + // a page turned just after the list loads is not sent back to page one. useEffect(() => { + const next = search.trim(); + if (next === query) return; const timer = setTimeout(() => { - setQuery(search.trim()); + setQuery(next); setPage(1); }, SEARCH_DEBOUNCE_MS); return () => clearTimeout(timer); - }, [search]); + }, [search, query]); const listMine = useCallback( () => diff --git a/panel/src/pages/admin/ImageBuildPage.test.tsx b/panel/src/pages/admin/ImageBuildPage.test.tsx index 368003d..c5d5cd7 100644 --- a/panel/src/pages/admin/ImageBuildPage.test.tsx +++ b/panel/src/pages/admin/ImageBuildPage.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest"; -import { render, screen, within } from "@testing-library/react"; +import { act, fireEvent, render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; import i18next from "i18next"; @@ -100,6 +100,21 @@ describe("ImageBuildPage list", () => { await vi.waitFor(() => expect(calls.listBuilds).toHaveBeenLastCalledWith({ query: undefined, limit: 10, offset: 10 })); }); + // The clock moves only when the test moves it, so the page is turned before + // the search box's pause has run out however fast the machine is. + it("keeps a page turned to right after the list loads", async () => { + vi.useFakeTimers(); + calls.listBuilds.mockResolvedValue(page([MINE], 25)); + renderPage(); + await act(() => vi.advanceTimersByTimeAsync(0)); + expect(screen.getByText(MINE.image_ref)).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: t("common:pagination_next") })); + await act(() => vi.advanceTimersByTimeAsync(0)); + expect(calls.listBuilds).toHaveBeenLastCalledWith({ query: undefined, limit: 10, offset: 10 }); + await act(() => vi.advanceTimersByTimeAsync(1_000)); + expect(calls.listBuilds).toHaveBeenLastCalledWith({ query: undefined, limit: 10, offset: 10 }); + }); + it("searches on the server, sending a status typed as the badge shows it by its code, from page one", async () => { await i18next.changeLanguage("zh-CN"); calls.listBuilds.mockResolvedValue(page([MINE], 25)); diff --git a/panel/src/pages/admin/ImageBuildPage.tsx b/panel/src/pages/admin/ImageBuildPage.tsx index cef151a..8760129 100644 --- a/panel/src/pages/admin/ImageBuildPage.tsx +++ b/panel/src/pages/admin/ImageBuildPage.tsx @@ -158,13 +158,17 @@ export function ImageBuildPage() { const [search, setSearch] = useState(""); const [query, setQuery] = useState(""); const [page, setPage] = useState(1); + // A search already asked (the first render's included) starts no timer, so + // a page turned just after the list loads is not sent back to page one. useEffect(() => { + const next = serverQuery(search, t); + if (next === query) return; const timer = setTimeout(() => { - setQuery(serverQuery(search, t)); + setQuery(next); setPage(1); }, SEARCH_DEBOUNCE_MS); return () => clearTimeout(timer); - }, [search, t]); + }, [search, t, query]); const listBuilds = useCallback( () => api.listBuilds({ query: query || undefined, limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE }), diff --git a/panel/src/pages/admin/SubmissionsPage.test.tsx b/panel/src/pages/admin/SubmissionsPage.test.tsx index 3564437..ca581f9 100644 --- a/panel/src/pages/admin/SubmissionsPage.test.tsx +++ b/panel/src/pages/admin/SubmissionsPage.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; import i18next from "i18next"; @@ -38,6 +38,7 @@ beforeEach(() => { calls.listSubmissions.mockResolvedValue(PAGE); }); afterEach(() => { + vi.useRealTimers(); vi.restoreAllMocks(); return i18next.changeLanguage("en-US"); }); @@ -84,6 +85,21 @@ describe("SubmissionsPage", () => { ); }); + // The clock moves only when the test moves it, so the page is turned before + // the search box's pause has run out however fast the machine is. + it("keeps a page turned to right after the queue loads", async () => { + vi.useFakeTimers(); + renderPage(); + await act(() => vi.advanceTimersByTimeAsync(0)); + expect(screen.getByText("Page 1 of 3")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + await act(() => vi.advanceTimersByTimeAsync(0)); + expect(calls.listSubmissions).toHaveBeenLastCalledWith({ limit: 10, offset: 10 }); + await act(() => vi.advanceTimersByTimeAsync(1_000)); + expect(calls.listSubmissions).toHaveBeenLastCalledWith({ limit: 10, offset: 10 }); + expect(screen.getByText("Page 2 of 3")).toBeTruthy(); + }); + it("steps back to the last page that still has rows when its own page comes back empty", async () => { // Reviews elsewhere shrank the queue to 20 rows: page 3 no longer exists. calls.listSubmissions.mockImplementation(async ({ offset }: { offset: number }) => diff --git a/panel/src/pages/admin/SubmissionsPage.tsx b/panel/src/pages/admin/SubmissionsPage.tsx index bf18fb7..6eef6b3 100644 --- a/panel/src/pages/admin/SubmissionsPage.tsx +++ b/panel/src/pages/admin/SubmissionsPage.tsx @@ -60,13 +60,17 @@ export function SubmissionsPage() { const [statusFilter, setStatusFilter] = useState<"all" | SubmissionStatus>("all"); const [page, setPage] = useState(1); const [expandedId, setExpandedId] = useState(null); + // A search already asked (the first render's included) starts no timer, so + // a page turned just after the queue loads is not sent back to page one. useEffect(() => { + const next = search.trim(); + if (next === query) return; const timer = setTimeout(() => { - setQuery(search.trim()); + setQuery(next); setPage(1); }, SEARCH_DEBOUNCE_MS); return () => clearTimeout(timer); - }, [search]); + }, [search, query]); const listSubmissions = useCallback( () =>