diff --git a/panel/src/pages/MySubmissionsPage.test.tsx b/panel/src/pages/MySubmissionsPage.test.tsx index 3fb90f1..afd1bbf 100644 --- a/panel/src/pages/MySubmissionsPage.test.tsx +++ b/panel/src/pages/MySubmissionsPage.test.tsx @@ -1,10 +1,11 @@ // @vitest-environment jsdom import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { act, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; import i18next from "i18next"; import { MySubmissionsPage } from "./MySubmissionsPage"; +import { humanizeError } from "@/lib/api"; import type { SubmissionPage } from "@/lib/types"; const calls = vi.hoisted(() => ({ @@ -49,6 +50,7 @@ beforeEach(() => { ); }); afterEach(() => { + vi.useRealTimers(); vi.restoreAllMocks(); return i18next.changeLanguage("en-US"); }); @@ -209,6 +211,64 @@ describe("MySubmissionsPage", () => { }); }); +describe("MySubmissionsPage keeps reading", () => { + const row = PAGE.submissions[0]; + const waiting: SubmissionPage = { + submissions: [{ ...row, status: "pending_review" }], + total: 1, + counts: { pending_review: 1, approved: 0, rejected: 0 }, + }; + const approved: SubmissionPage = { + submissions: [{ ...row, status: "approved" }], + total: 1, + counts: { pending_review: 0, approved: 1, rejected: 0 }, + }; + const renderPage = () => + render( + + + , + ); + + it("picks up a review verdict at the slow pace", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + calls.listMySubmissions.mockResolvedValue(waiting); + renderPage(); + await screen.findByRole("button", { name: "Pending Review (1)" }); + const before = calls.listMySubmissions.mock.calls.length; + + calls.listMySubmissions.mockResolvedValue(approved); + await act(() => vi.advanceTimersByTimeAsync(14_000)); + expect(calls.listMySubmissions.mock.calls.length).toBe(before); + await act(() => vi.advanceTimersByTimeAsync(1_000)); + expect(calls.listMySubmissions.mock.calls.length).toBe(before + 1); + expect(await screen.findByRole("button", { name: "Approved (1)" })).toBeTruthy(); + }); + + it.each(["pending", "building"] as const)("rereads fast while a shown build is %s", async (build_status) => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + calls.listMySubmissions.mockResolvedValue({ ...approved, submissions: [{ ...row, build_id: "b-1", build_status }] }); + renderPage(); + await screen.findByText("Create Above and Beyond"); + const before = calls.listMySubmissions.mock.calls.length; + await act(() => vi.advanceTimersByTimeAsync(4_000)); + expect(calls.listMySubmissions.mock.calls.length).toBe(before + 1); + }); + + it("keeps the list when a later read fails, and says why", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + calls.listMySubmissions.mockResolvedValue(waiting); + renderPage(); + await screen.findByRole("button", { name: "Pending Review (1)" }); + const outage = { status: 503, code: "unavailable", message: "list unavailable" }; + calls.listMySubmissions.mockRejectedValue(outage); + await act(() => vi.advanceTimersByTimeAsync(15_000)); + expect((await screen.findByRole("alert")).textContent).toContain(humanizeError(outage)); + expect(screen.getByText("Create Above and Beyond")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Pending Review (1)" })).toBeTruthy(); + }); +}); + // openFormWith opens the submit dialog, names the pack and picks a 4 KiB file. async function openFormWith(name: string): Promise { render( diff --git a/panel/src/pages/MySubmissionsPage.tsx b/panel/src/pages/MySubmissionsPage.tsx index 0ec7966..1ea8994 100644 --- a/panel/src/pages/MySubmissionsPage.tsx +++ b/panel/src/pages/MySubmissionsPage.tsx @@ -32,14 +32,14 @@ import { import { cn } from "@/lib/utils"; import { MessageLine } from "@/components/MessageLine"; import { InlineConfirm } from "@/components/InlineConfirm"; -import { Loading, ErrorState, EmptyState } from "@/components/States"; +import { Loading, ErrorState, EmptyState, RefreshError } from "@/components/States"; import { Pagination } from "@/components/Pagination"; import { StatCard } from "@/components/StatCard"; import { PageHeader } from "@/components/PageHeader"; import { SubmissionStatusBadge } from "@/components/SubmissionStatusBadge"; import { api, humanizeError } from "@/lib/api"; import { uploadContext } from "@/lib/contextUpload"; -import { useAsync } from "@/lib/hooks"; +import { STATUS_POLL_FAST_MS, STATUS_POLL_SLOW_MS, useAsync, usePolling } from "@/lib/hooks"; import { formatRelative, formatAbsolute } from "@/lib/format"; import type { BuildStatus, Submission, SubmissionStatus } from "@/lib/types"; @@ -134,6 +134,11 @@ export function MySubmissionsPage() { const { data, error: fetchError, loading, reload } = useAsync(listMine, [listMine], { keepPrevious: true }); const submissions = useMemo(() => data?.submissions ?? [], [data]); const matching = data?.total ?? 0; + // A review lands whenever an admin gets to it and an approved pack builds on + // its own, so the list keeps reading: every few seconds while a shown build + // is still under way, at the slow pace otherwise. + const building = submissions.some((s) => s.build_status === "pending" || s.build_status === "building"); + usePolling(reload, building ? STATUS_POLL_FAST_MS : STATUS_POLL_SLOW_MS); // The cards and filter chips count everything this player submitted, whatever is filtered. const stats = useMemo(() => { @@ -316,6 +321,8 @@ export function MySubmissionsPage() { {/* Row-action error (withdraw) */} {actionError && } + {/* A later read that failed keeps the last list and says so above it. */} + {!!fetchError && !!data && } {/* Stats Cards Row */}
@@ -398,7 +405,7 @@ export function MySubmissionsPage() { {/* List Content */} {loading && !data ? (
- ) : fetchError ? ( + ) : fetchError && !data ? (
) : submissions.length === 0 ? (