Loading panel/src/pages/MySubmissionsPage.test.tsx +61 −1 Changes for panel/src/pages/MySubmissionsPage.test.tsx: 61 added lines, 1 removed line. Original line number Diff line number Diff line // @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(() => ({ Loading Loading @@ -49,6 +50,7 @@ beforeEach(() => { ); }); afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); return i18next.changeLanguage("en-US"); }); Loading Loading @@ -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( <MemoryRouter> <MySubmissionsPage /> </MemoryRouter>, ); 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<File> { render( Loading panel/src/pages/MySubmissionsPage.tsx +10 −3 Changes for panel/src/pages/MySubmissionsPage.tsx: 10 added lines, 3 removed lines. Original line number Diff line number Diff line Loading @@ -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"; Loading Loading @@ -134,6 +134,11 @@ export function MySubmissionsPage() { const { data, error: fetchError, loading, reload } = useAsync(listMine, [listMine], { keepPrevious: true }); const submissions = useMemo<Submission[]>(() => 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(() => { Loading Loading @@ -316,6 +321,8 @@ export function MySubmissionsPage() { {/* Row-action error (withdraw) */} {actionError && <MessageLine kind="error" message={actionError} compact />} {/* A later read that failed keeps the last list and says so above it. */} {!!fetchError && !!data && <RefreshError error={fetchError} />} {/* Stats Cards Row */} <div className="grid grid-cols-1 gap-4 sm:grid-cols-4"> Loading Loading @@ -398,7 +405,7 @@ export function MySubmissionsPage() { {/* List Content */} {loading && !data ? ( <div className="py-12"><Loading /></div> ) : fetchError ? ( ) : fetchError && !data ? ( <div className="py-12"><ErrorState error={fetchError} onRetry={reload} /></div> ) : submissions.length === 0 ? ( <div className="p-4 border-b-0"> Loading Loading
panel/src/pages/MySubmissionsPage.test.tsx +61 −1 Changes for panel/src/pages/MySubmissionsPage.test.tsx: 61 added lines, 1 removed line. Original line number Diff line number Diff line // @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(() => ({ Loading Loading @@ -49,6 +50,7 @@ beforeEach(() => { ); }); afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); return i18next.changeLanguage("en-US"); }); Loading Loading @@ -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( <MemoryRouter> <MySubmissionsPage /> </MemoryRouter>, ); 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<File> { render( Loading
panel/src/pages/MySubmissionsPage.tsx +10 −3 Changes for panel/src/pages/MySubmissionsPage.tsx: 10 added lines, 3 removed lines. Original line number Diff line number Diff line Loading @@ -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"; Loading Loading @@ -134,6 +134,11 @@ export function MySubmissionsPage() { const { data, error: fetchError, loading, reload } = useAsync(listMine, [listMine], { keepPrevious: true }); const submissions = useMemo<Submission[]>(() => 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(() => { Loading Loading @@ -316,6 +321,8 @@ export function MySubmissionsPage() { {/* Row-action error (withdraw) */} {actionError && <MessageLine kind="error" message={actionError} compact />} {/* A later read that failed keeps the last list and says so above it. */} {!!fetchError && !!data && <RefreshError error={fetchError} />} {/* Stats Cards Row */} <div className="grid grid-cols-1 gap-4 sm:grid-cols-4"> Loading Loading @@ -398,7 +405,7 @@ export function MySubmissionsPage() { {/* List Content */} {loading && !data ? ( <div className="py-12"><Loading /></div> ) : fetchError ? ( ) : fetchError && !data ? ( <div className="py-12"><ErrorState error={fetchError} onRetry={reload} /></div> ) : submissions.length === 0 ? ( <div className="p-4 border-b-0"> Loading