feat(api): 集群列表读改走 informer 缓存,审核队列、我的提交与备份列表改为服务端分页筛选并一次批量查构建,面板三页跟进
This commit is contained in:
41 files changed
+1642
-295
No files matched your search
+34
-9
@@ -1009,13 +1009,21 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
|
||||
sendJSON(ctx.res, 200, { servers: fleetView(ctx.state, ctx.account) });
|
||||
return true;
|
||||
case "GET backups":
|
||||
// Admin sees every archive; a user only worlds they formerly owned — mirrors
|
||||
// AllBackups vs BackupsForUser. The panel filters by server_name client-side.
|
||||
sendJSON(ctx.res, 200, {
|
||||
backups: ctx.state.backups.filter(
|
||||
(b) => isAdmin(ctx.account.role) || b.former_owner === ctx.account.id,
|
||||
),
|
||||
});
|
||||
{
|
||||
// Admin sees every archive; a user only worlds they formerly owned — mirrors
|
||||
// AllBackups vs BackupsForUser. server narrows inside that scope, and rows
|
||||
// come a page at a time, newest first, with the matching total.
|
||||
const url = new URL(ctx.req.url ?? "/", "http://localhost");
|
||||
const server = url.searchParams.get("server") ?? "";
|
||||
const limit = Math.min(Number(url.searchParams.get("limit")) || 20, 100);
|
||||
const offset = Math.max(Number(url.searchParams.get("offset")) || 0, 0);
|
||||
const matched = ctx.state.backups
|
||||
.filter((b) => b.status === "present")
|
||||
.filter((b) => isAdmin(ctx.account.role) || b.former_owner === ctx.account.id)
|
||||
.filter((b) => !server || b.server_name === server)
|
||||
.sort((a, b) => b.created_at.localeCompare(a.created_at) || b.id.localeCompare(a.id));
|
||||
sendJSON(ctx.res, 200, { backups: matched.slice(offset, offset + limit), total: matched.length });
|
||||
}
|
||||
return true;
|
||||
case "POST servers":
|
||||
await createServerRoute(ctx);
|
||||
@@ -1698,6 +1706,23 @@ async function handleImageRoute(ctx: SessionContext): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The same page as submit.PGStore.PageSubmissions: counts cover the whole scope,
|
||||
// status and query (id, submitter or name, any part) narrow the rows, newest first.
|
||||
function submissionPageOf(ctx: SessionContext, scope: Submission[]) {
|
||||
const url = new URL(ctx.req.url ?? "/", "http://localhost");
|
||||
const status = url.searchParams.get("status") ?? "";
|
||||
const q = (url.searchParams.get("query") ?? "").trim().toLowerCase();
|
||||
const limit = Math.min(Number(url.searchParams.get("limit")) || 20, 100);
|
||||
const offset = Math.max(Number(url.searchParams.get("offset")) || 0, 0);
|
||||
const counts = { pending_review: 0, approved: 0, rejected: 0 };
|
||||
for (const s of scope) counts[s.status] += 1;
|
||||
const matched = scope
|
||||
.filter((s) => !status || s.status === status)
|
||||
.filter((s) => !q || [s.id, s.submitted_by, s.display_name].some((f) => f.toLowerCase().includes(q)))
|
||||
.sort((a, b) => b.created_at.localeCompare(a.created_at) || b.id.localeCompare(a.id));
|
||||
return { submissions: matched.slice(offset, offset + limit), total: matched.length, counts };
|
||||
}
|
||||
|
||||
async function handleSubmissionRoute(ctx: SessionContext): Promise<boolean> {
|
||||
const isAdminSubmissions = ctx.parts[2] === "submissions";
|
||||
const isMeSubmissions = ctx.parts[2] === "me" && ctx.parts[3] === "submissions";
|
||||
@@ -1706,7 +1731,7 @@ async function handleSubmissionRoute(ctx: SessionContext): Promise<boolean> {
|
||||
// GET /api/v1/me/submissions
|
||||
if (isMeSubmissions && is("GET", ctx) && ctx.parts.length === 4) {
|
||||
const userSubs = ctx.state.submissions.filter((s) => s.submitted_by === ctx.account.email);
|
||||
sendJSON(ctx.res, 200, { submissions: userSubs });
|
||||
sendJSON(ctx.res, 200, submissionPageOf(ctx, userSubs));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1790,7 +1815,7 @@ async function handleSubmissionRoute(ctx: SessionContext): Promise<boolean> {
|
||||
sendError(ctx.res, 403, "forbidden", "admin account required");
|
||||
return true;
|
||||
}
|
||||
sendJSON(ctx.res, 200, { submissions: ctx.state.submissions });
|
||||
sendJSON(ctx.res, 200, submissionPageOf(ctx, ctx.state.submissions));
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
"build_status_cancelled": "Cancelled",
|
||||
"no_submissions_title": "No Submissions Found",
|
||||
"no_submissions_hint": "You haven't submitted any modpacks yet.",
|
||||
"search_no_results": "No matches",
|
||||
"search_no_results_hint": "Try a different search term or filter.",
|
||||
"search_placeholder": "Search submissions...",
|
||||
"error_file_type": "Please upload a valid .tar.gz file.",
|
||||
"error_file_size": "File exceeds the allowed size limit.",
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
"build_status_cancelled": "已取消",
|
||||
"no_submissions_title": "暂无提交记录",
|
||||
"no_submissions_hint": "您还没有提交过任何模组包。",
|
||||
"search_no_results": "无匹配结果",
|
||||
"search_no_results_hint": "尝试更换搜索词或筛选条件。",
|
||||
"search_placeholder": "搜索提交记录...",
|
||||
"error_file_type": "请上传有效的 .tar.gz 压缩文件。",
|
||||
"error_file_size": "文件超过了允许的大小限制。",
|
||||
|
||||
@@ -487,17 +487,26 @@ describe("image whitelist and builds wire shapes", () => {
|
||||
});
|
||||
|
||||
describe("submissions", () => {
|
||||
it("listSubmissions GETs from /submissions", async () => {
|
||||
it("listSubmissions GETs a page from /submissions", async () => {
|
||||
const submissions = [{ id: "sub-1", display_name: "test", status: "pending_review" }];
|
||||
const fetchSpy = fakeFetch({ submissions });
|
||||
const fetchSpy = fakeFetch({ submissions, total: 31, counts: { pending_review: 4, approved: 27, rejected: 0 } });
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const res = await api.listSubmissions();
|
||||
expect(res).toEqual(submissions);
|
||||
expect(res).toEqual({ submissions, total: 31, counts: { pending_review: 4, approved: 27, rejected: 0 } });
|
||||
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(String(url)).toBe("/submissions");
|
||||
expect((opts as RequestInit).method).toBe("GET");
|
||||
});
|
||||
|
||||
it("listSubmissions puts the filter and page on the query string", async () => {
|
||||
const fetchSpy = fakeFetch({ submissions: [], total: 0, counts: {} });
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const res = await api.listSubmissions({ status: "approved", query: "sky block", limit: 10, offset: 20 });
|
||||
expect(res).toEqual({ submissions: [], total: 0, counts: { pending_review: 0, approved: 0, rejected: 0 } });
|
||||
const [url] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(String(url)).toBe("/submissions?status=approved&query=sky+block&limit=10&offset=20");
|
||||
});
|
||||
|
||||
it("approveSubmission POSTs to /submissions/{id}/approve", async () => {
|
||||
const sub = { id: "sub-1", status: "approved" };
|
||||
const fetchSpy = fakeFetch(sub);
|
||||
@@ -544,14 +553,14 @@ describe("image whitelist and builds wire shapes", () => {
|
||||
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({ reason: "bad" });
|
||||
});
|
||||
|
||||
it("listMySubmissions GETs from /me/submissions", async () => {
|
||||
it("listMySubmissions GETs a page from /me/submissions", async () => {
|
||||
const submissions = [{ id: "sub-2", display_name: "my test", status: "pending_review" }];
|
||||
const fetchSpy = fakeFetch({ submissions });
|
||||
const fetchSpy = fakeFetch({ submissions, total: 1, counts: { pending_review: 1, approved: 0, rejected: 2 } });
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const res = await api.listMySubmissions();
|
||||
expect(res).toEqual(submissions);
|
||||
const res = await api.listMySubmissions({ status: "rejected", offset: 10 });
|
||||
expect(res).toEqual({ submissions, total: 1, counts: { pending_review: 1, approved: 0, rejected: 2 } });
|
||||
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(String(url)).toBe("/me/submissions");
|
||||
expect(String(url)).toBe("/me/submissions?status=rejected&offset=10");
|
||||
expect((opts as RequestInit).method).toBe("GET");
|
||||
});
|
||||
|
||||
@@ -663,6 +672,26 @@ describe("image whitelist and builds wire shapes", () => {
|
||||
expect((opts as RequestInit).body).toBeUndefined();
|
||||
});
|
||||
|
||||
it("listBackups asks for one server's page and keeps the total", async () => {
|
||||
const backup = { id: "bk1", server_name: "survival", status: "present", created_at: "2026-09-01T00:00:00Z" };
|
||||
const fetchSpy = fakeFetch({ backups: [backup], total: 45 });
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const res = await api.listBackups({ server: "survival", limit: 20, offset: 40 });
|
||||
expect(res).toEqual({ backups: [backup], total: 45 });
|
||||
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(String(url)).toBe("/backups?server=survival&limit=20&offset=40");
|
||||
expect((opts as RequestInit).method).toBe("GET");
|
||||
});
|
||||
|
||||
it("listBackups with no filter reads /backups and fills an empty page", async () => {
|
||||
const fetchSpy = fakeFetch({});
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const res = await api.listBackups();
|
||||
expect(res).toEqual({ backups: [], total: 0 });
|
||||
const [url] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(String(url)).toBe("/backups");
|
||||
});
|
||||
|
||||
it("restoreBackup sends backup_id, and safety_snapshot only when turned off", async () => {
|
||||
const fetchSpy = fakeFetch(
|
||||
{ name: "survival", status: "restoring", backup_id: "bk1", safety_snapshot: true },
|
||||
|
||||
+38
-10
@@ -28,6 +28,8 @@ import type {
|
||||
WhitelistImage,
|
||||
WhitelistResult,
|
||||
Submission,
|
||||
SubmissionListParams,
|
||||
SubmissionPage,
|
||||
UpdateWindow,
|
||||
DBBackupStatus,
|
||||
} from "./types";
|
||||
@@ -222,6 +224,26 @@ function rejectingSync<T extends Record<string, unknown>>(methods: T): T {
|
||||
return out as T;
|
||||
}
|
||||
|
||||
// submissionPage reads one page of a submission list. Filtering and paging run on
|
||||
// the server; a status count the API left out reads as zero.
|
||||
function submissionPage(path: string, params?: SubmissionListParams): Promise<SubmissionPage> {
|
||||
const sp = new URLSearchParams();
|
||||
if (params?.status) sp.set("status", params.status);
|
||||
if (params?.query) sp.set("query", params.query);
|
||||
if (params?.limit) sp.set("limit", String(params.limit));
|
||||
if (params?.offset) sp.set("offset", String(params.offset));
|
||||
const qs = sp.toString();
|
||||
return request<Partial<SubmissionPage>>("GET", `${path}${qs ? `?${qs}` : ""}`).then((r) => ({
|
||||
submissions: r.submissions ?? [],
|
||||
total: r.total ?? 0,
|
||||
counts: {
|
||||
pending_review: r.counts?.pending_review ?? 0,
|
||||
approved: r.counts?.approved ?? 0,
|
||||
rejected: r.counts?.rejected ?? 0,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
// Setup bootstrap (spec §B). The one-time token from `felis setup` is redeemed for
|
||||
// a lockdown session; the response (and /setup/status) reports which onboarding
|
||||
// steps remain so the Setup wizard can drive email verification + passkey enrollment.
|
||||
@@ -464,12 +486,19 @@ export const api = rejectingSync({
|
||||
|
||||
// World backups (spec §7). listBackups is the app-tier read: an admin sees every
|
||||
// present backup, a user only the backups of worlds they formerly owned — the
|
||||
// scope is decided server-side from the principal, not by any client filter, so a
|
||||
// user cannot widen it. Only present (restorable) rows come back, newest first;
|
||||
// there is no per-server backups endpoint, so the panel filters by server_name
|
||||
// client-side and the first matching row is the one a restore would recover.
|
||||
listBackups: () =>
|
||||
request<{ backups: BackupView[] }>("GET", "/backups").then((r) => r.backups ?? []),
|
||||
// scope is decided server-side from the principal, so a user cannot widen it.
|
||||
// server narrows the page to one server inside that scope. Only present
|
||||
// (restorable) rows come back, newest first, one page at a time with the total.
|
||||
listBackups: (params?: { server?: string; limit?: number; offset?: number }) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params?.server) sp.set("server", params.server);
|
||||
if (params?.limit) sp.set("limit", String(params.limit));
|
||||
if (params?.offset) sp.set("offset", String(params.offset));
|
||||
const qs = sp.toString();
|
||||
return request<{ backups: BackupView[]; total: number }>("GET", `/backups${qs ? `?${qs}` : ""}`).then(
|
||||
(r) => ({ backups: r.backups ?? [], total: r.total ?? 0 }),
|
||||
);
|
||||
},
|
||||
|
||||
// restoreBackup starts an ASYNC restore of a server's world from a backup
|
||||
// (spec §7 POST restore-backup). It accepts an optional backupId in the body: when
|
||||
@@ -650,8 +679,8 @@ export const api = rejectingSync({
|
||||
{ code },
|
||||
),
|
||||
|
||||
listSubmissions: () =>
|
||||
request<{ submissions: Submission[] }>("GET", "/submissions").then((r) => r.submissions ?? []),
|
||||
// The admin review queue, one page at a time (see submissionPage).
|
||||
listSubmissions: (params?: SubmissionListParams) => submissionPage("/submissions", params),
|
||||
|
||||
// expectedDigest is the sha256 of the context the reviewer looked at; the API
|
||||
// refuses the approval (409 context_changed) when the upload has since changed.
|
||||
@@ -684,8 +713,7 @@ export const api = rejectingSync({
|
||||
return digest;
|
||||
},
|
||||
|
||||
listMySubmissions: () =>
|
||||
request<{ submissions: Submission[] }>("GET", "/me/submissions").then((r) => r.submissions ?? []),
|
||||
listMySubmissions: (params?: SubmissionListParams) => submissionPage("/me/submissions", params),
|
||||
|
||||
createSubmission: (displayName: string) =>
|
||||
request<Submission>("POST", "/me/submissions", { display_name: displayName }),
|
||||
|
||||
@@ -1054,7 +1054,10 @@ export interface paths {
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** List world backups (admin sees all; a user sees only worlds they formerly owned). */
|
||||
/**
|
||||
* List world backups (admin sees all; a user sees only worlds they formerly owned).
|
||||
* @description One page of the present backups in the caller's scope, newest first. server narrows the page to one server's backups inside that scope; it never widens it.
|
||||
*/
|
||||
get: operations["listBackups"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
@@ -4933,14 +4936,19 @@ export interface operations {
|
||||
};
|
||||
listBackups: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
query?: {
|
||||
/** @description Only this server's backups */
|
||||
server?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Visible backups. */
|
||||
/** @description A page of visible backups plus how many match. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
@@ -4948,9 +4956,11 @@ export interface operations {
|
||||
content: {
|
||||
"application/json": {
|
||||
backups: components["schemas"]["BackupView"][];
|
||||
total: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
400: components["responses"]["BadRequest"];
|
||||
401: components["responses"]["Unauthorized"];
|
||||
};
|
||||
};
|
||||
@@ -6888,14 +6898,20 @@ export interface operations {
|
||||
};
|
||||
mySubmissions: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
query?: {
|
||||
status?: "pending_review" | "approved" | "rejected";
|
||||
/** @description Part of the id, the submitter or the display name; case-insensitive */
|
||||
query?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description The caller's submissions, newest first; rows with a linked build additionally carry build_status/build_error so the submitter can see whether their build succeeded or failed (and why). */
|
||||
/** @description One page of the caller's submissions, newest first; rows with a linked build additionally carry build_status/build_error so the submitter can see whether their build succeeded or failed (and why). */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
@@ -6903,9 +6919,18 @@ export interface operations {
|
||||
content: {
|
||||
"application/json": {
|
||||
submissions: components["schemas"]["Submission"][];
|
||||
/** @description How many submissions match status and query in all. */
|
||||
total: number;
|
||||
/** @description How many of the scope's submissions sit in each status, whatever status and query say. */
|
||||
counts: {
|
||||
pending_review: number;
|
||||
approved: number;
|
||||
rejected: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
400: components["responses"]["BadRequest"];
|
||||
401: components["responses"]["Unauthorized"];
|
||||
503: components["responses"]["ServiceUnavailable"];
|
||||
};
|
||||
@@ -7322,14 +7347,20 @@ export interface operations {
|
||||
};
|
||||
listSubmissions: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
query?: {
|
||||
status?: "pending_review" | "approved" | "rejected";
|
||||
/** @description Part of the id, the submitter or the display name; case-insensitive */
|
||||
query?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description All submissions, newest first. */
|
||||
/** @description One page of every user's submissions, newest first. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
@@ -7337,9 +7368,18 @@ export interface operations {
|
||||
content: {
|
||||
"application/json": {
|
||||
submissions: components["schemas"]["Submission"][];
|
||||
/** @description How many submissions match status and query in all. */
|
||||
total: number;
|
||||
/** @description How many of the scope's submissions sit in each status, whatever status and query say. */
|
||||
counts: {
|
||||
pending_review: number;
|
||||
approved: number;
|
||||
rejected: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
400: components["responses"]["BadRequest"];
|
||||
401: components["responses"]["Unauthorized"];
|
||||
403: components["responses"]["Forbidden"];
|
||||
503: components["responses"]["ServiceUnavailable"];
|
||||
|
||||
@@ -331,6 +331,23 @@ export interface Submission {
|
||||
context_sha256?: string;
|
||||
}
|
||||
|
||||
/** SubmissionListParams picks one page of a submission list (server-side filter
|
||||
* and paging; the scope is the endpoint, never a parameter). */
|
||||
export interface SubmissionListParams {
|
||||
status?: SubmissionStatus;
|
||||
query?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
/** SubmissionPage is one page of a submission list: total counts the rows that
|
||||
* match status and query, counts the scope's rows per status regardless. */
|
||||
export interface SubmissionPage {
|
||||
submissions: Submission[];
|
||||
total: number;
|
||||
counts: Record<SubmissionStatus, number>;
|
||||
}
|
||||
|
||||
export interface UpdateWindow {
|
||||
start: string | null;
|
||||
end: string | null;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { 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 type { SubmissionPage } from "@/lib/types";
|
||||
|
||||
const calls = vi.hoisted(() => ({
|
||||
listMySubmissions: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/config", () => ({ loadConfig: () => Promise.resolve({}) }));
|
||||
vi.mock("@/lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/api")>();
|
||||
return { ...actual, api: { ...actual.api, ...calls } };
|
||||
});
|
||||
|
||||
// One row of this player's 14 submissions.
|
||||
const PAGE: SubmissionPage = {
|
||||
submissions: [
|
||||
{
|
||||
id: "sub-3",
|
||||
submitted_by: "user-1",
|
||||
display_name: "Create Above and Beyond",
|
||||
context_ref: "submissions/sub-3.tar.gz",
|
||||
status: "approved",
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
total: 14,
|
||||
counts: { pending_review: 2, approved: 9, rejected: 3 },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
calls.listMySubmissions.mockReset();
|
||||
// A filtered page matches fewer rows; the counts still cover everything.
|
||||
calls.listMySubmissions.mockImplementation(async ({ status }: { status?: string }) =>
|
||||
status ? { ...PAGE, total: 3 } : PAGE,
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
return i18next.changeLanguage("en-US");
|
||||
});
|
||||
|
||||
describe("MySubmissionsPage", () => {
|
||||
it("counts every submission from the server and asks it for the filtered page", async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MySubmissionsPage />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
await screen.findByText("Create Above and Beyond");
|
||||
expect(screen.getByRole("button", { name: "All Statuses (14)" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Rejected (3)" })).toBeTruthy();
|
||||
expect(screen.getByText("Page 1 of 2")).toBeTruthy();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||
await vi.waitFor(() => expect(calls.listMySubmissions).toHaveBeenLastCalledWith({ limit: 10, offset: 10 }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Rejected (3)" }));
|
||||
await userEvent.type(screen.getByPlaceholderText("Search submissions..."), "create");
|
||||
await vi.waitFor(() =>
|
||||
expect(calls.listMySubmissions).toHaveBeenLastCalledWith({ status: "rejected", query: "create", limit: 10, offset: 0 }),
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "All Statuses (14)" })).toBeTruthy();
|
||||
await vi.waitFor(() => expect(screen.queryByText(/^Page \d+ of/)).toBeNull());
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo, useRef } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Upload,
|
||||
@@ -43,6 +43,7 @@ import { formatRelative, formatAbsolute } from "@/lib/format";
|
||||
import type { BuildStatus, Submission, SubmissionStatus } from "@/lib/types";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
// The linked build's outcome as shown in a row's expanded details. Colors mirror
|
||||
// the admin build page; the labels are player-facing, so they come from this
|
||||
@@ -77,10 +78,6 @@ export function MySubmissionsPage() {
|
||||
const locale = i18n.language;
|
||||
const now = Date.now();
|
||||
|
||||
// Async API hook
|
||||
const { data, error: fetchError, loading, reload } = useAsync(() => api.listMySubmissions(), []);
|
||||
const submissions = useMemo<Submission[]>(() => data ?? [], [data]);
|
||||
|
||||
// Dialog State
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
@@ -100,53 +97,50 @@ export function MySubmissionsPage() {
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Search & Filtering State
|
||||
// Search & Filtering State: the server filters and pages, the search box
|
||||
// settles for a moment before it asks.
|
||||
const [search, setSearch] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | SubmissionStatus>("all");
|
||||
const [page, setPage] = useState(1);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setQuery(search.trim());
|
||||
setPage(1);
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search]);
|
||||
|
||||
// Filtered & Paginated Submissions
|
||||
const filteredSubmissions = useMemo(() => {
|
||||
let list = [...submissions];
|
||||
const listMine = useCallback(
|
||||
() =>
|
||||
api.listMySubmissions({
|
||||
status: statusFilter === "all" ? undefined : statusFilter,
|
||||
query: query || undefined,
|
||||
limit: PAGE_SIZE,
|
||||
offset: (page - 1) * PAGE_SIZE,
|
||||
}),
|
||||
[statusFilter, query, page],
|
||||
);
|
||||
const { data, error: fetchError, loading, reload } = useAsync(listMine, [listMine], { keepPrevious: true });
|
||||
const submissions = useMemo<Submission[]>(() => data?.submissions ?? [], [data]);
|
||||
const matching = data?.total ?? 0;
|
||||
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
list = list.filter(
|
||||
(s) =>
|
||||
s.display_name.toLowerCase().includes(q) ||
|
||||
s.id.toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
|
||||
if (statusFilter !== "all") {
|
||||
list = list.filter((s) => s.status === statusFilter);
|
||||
}
|
||||
|
||||
return list;
|
||||
}, [submissions, search, statusFilter]);
|
||||
|
||||
// Reset page when filter changes
|
||||
const lastFilterKey = `${search}-${statusFilter}`;
|
||||
const [prevFilterKey, setPrevFilterKey] = useState(lastFilterKey);
|
||||
if (prevFilterKey !== lastFilterKey) {
|
||||
setPage(1);
|
||||
setPrevFilterKey(lastFilterKey);
|
||||
}
|
||||
|
||||
const paginatedSubmissions = useMemo(() => {
|
||||
const start = (page - 1) * PAGE_SIZE;
|
||||
return filteredSubmissions.slice(start, start + PAGE_SIZE);
|
||||
}, [filteredSubmissions, page]);
|
||||
|
||||
// Stats
|
||||
// The cards and filter chips count everything this player submitted, whatever is filtered.
|
||||
const stats = useMemo(() => {
|
||||
const total = submissions.length;
|
||||
const pending = submissions.filter((s) => s.status === "pending_review").length;
|
||||
const approved = submissions.filter((s) => s.status === "approved").length;
|
||||
const rejected = submissions.filter((s) => s.status === "rejected").length;
|
||||
return { total, pending, approved, rejected };
|
||||
}, [submissions]);
|
||||
const c = data?.counts ?? { pending_review: 0, approved: 0, rejected: 0 };
|
||||
return {
|
||||
total: c.pending_review + c.approved + c.rejected,
|
||||
pending: c.pending_review,
|
||||
approved: c.approved,
|
||||
rejected: c.rejected,
|
||||
};
|
||||
}, [data]);
|
||||
const filtering = query !== "" || statusFilter !== "all";
|
||||
// A withdraw can empty the last page; step back to the one that now is.
|
||||
useEffect(() => {
|
||||
if (data && data.submissions.length === 0 && page > 1) setPage(Math.max(1, Math.ceil(data.total / PAGE_SIZE)));
|
||||
}, [data, page]);
|
||||
|
||||
// Drag and drop event handlers
|
||||
const handleDrag = (e: React.DragEvent) => {
|
||||
@@ -361,11 +355,11 @@ export function MySubmissionsPage() {
|
||||
<div className="py-12"><Loading /></div>
|
||||
) : fetchError ? (
|
||||
<div className="py-12"><ErrorState error={fetchError} onRetry={reload} /></div>
|
||||
) : filteredSubmissions.length === 0 ? (
|
||||
) : submissions.length === 0 ? (
|
||||
<div className="p-4 border-b-0">
|
||||
<EmptyState
|
||||
title={t("no_submissions_title")}
|
||||
hint={t("no_submissions_hint")}
|
||||
title={filtering ? t("search_no_results") : t("no_submissions_title")}
|
||||
hint={filtering ? t("search_no_results_hint") : t("no_submissions_hint")}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
@@ -379,7 +373,7 @@ export function MySubmissionsPage() {
|
||||
|
||||
{/* Table Rows */}
|
||||
<div className="divide-y divide-border/60">
|
||||
{paginatedSubmissions.map((sub) => {
|
||||
{submissions.map((sub) => {
|
||||
const isExpanded = expandedId === sub.id;
|
||||
return (
|
||||
<div key={sub.id} className="flex flex-col">
|
||||
@@ -528,11 +522,11 @@ export function MySubmissionsPage() {
|
||||
</div>
|
||||
|
||||
{/* Pagination Footer */}
|
||||
{filteredSubmissions.length > PAGE_SIZE && (
|
||||
{matching > PAGE_SIZE && (
|
||||
<div className="p-4 border-t">
|
||||
<Pagination
|
||||
page={page}
|
||||
total={filteredSubmissions.length}
|
||||
total={matching}
|
||||
pageSize={PAGE_SIZE}
|
||||
onChange={setPage}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import i18next from "i18next";
|
||||
import { ServerBackups } from "./ServerBackups";
|
||||
import type { BackupView } from "@/lib/types";
|
||||
|
||||
const calls = vi.hoisted(() => ({
|
||||
status: vi.fn(),
|
||||
myServers: vi.fn(),
|
||||
serverJobs: vi.fn(),
|
||||
listBackups: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/tier", () => ({
|
||||
useTier: () => ({
|
||||
loading: false,
|
||||
identity: { user_id: "admin-1", email: "[email protected]", role: "admin" },
|
||||
isAdmin: true,
|
||||
isOwner: false,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/lib/config", () => ({ loadConfig: () => Promise.resolve({}) }));
|
||||
vi.mock("@/lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/api")>();
|
||||
return { ...actual, api: { ...actual.api, ...calls } };
|
||||
});
|
||||
|
||||
function backup(id: string, hoursAgo: number, corrupt = false): BackupView {
|
||||
return {
|
||||
id,
|
||||
server_name: "survival",
|
||||
former_owner: "user-1",
|
||||
size_bytes: 1024 * 1024,
|
||||
reason: "manual",
|
||||
status: "present",
|
||||
created_at: new Date(Date.now() - hoursAgo * 3600_000).toISOString(),
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600_000).toISOString(),
|
||||
corrupt,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const fn of Object.values(calls)) fn.mockReset();
|
||||
calls.status.mockResolvedValue({ name: "survival", displayName: "Survival", phase: "Stopped" });
|
||||
calls.myServers.mockResolvedValue([]);
|
||||
calls.serverJobs.mockResolvedValue([]);
|
||||
// 45 backups of this server; the API hands them over 20 at a time.
|
||||
calls.listBackups.mockImplementation(async ({ offset }: { offset: number }) => ({
|
||||
backups: offset === 0 ? [backup("bk-new", 1, true), backup("bk-ok", 2)] : [backup(`bk-${offset}`, 50)],
|
||||
total: 45,
|
||||
}));
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
return i18next.changeLanguage("en-US");
|
||||
});
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={["/servers/survival/backups"]}>
|
||||
<Routes>
|
||||
<Route path="/servers/:name/backups" element={<ServerBackups />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ServerBackups", () => {
|
||||
it("reads only this server's backups a page at a time, and marks the latest on the first page", async () => {
|
||||
renderPage();
|
||||
expect(await screen.findByText("Page 1 of 3")).toBeTruthy();
|
||||
expect(calls.listBackups).toHaveBeenLastCalledWith({ server: "survival", limit: 20, offset: 0 });
|
||||
expect(screen.getAllByText("Latest backup")).toHaveLength(1);
|
||||
// The newest row failed its read-back; the latest restorable one is the next.
|
||||
expect(screen.getByText("Latest backup").closest("tr")?.textContent).toContain("2 hours ago");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||
expect(await screen.findByText("Page 2 of 3")).toBeTruthy();
|
||||
expect(calls.listBackups).toHaveBeenLastCalledWith({ server: "survival", limit: 20, offset: 20 });
|
||||
expect(screen.queryByText("Latest backup")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows no pager when the server's backups fit on one page", async () => {
|
||||
calls.listBackups.mockResolvedValue({ backups: [backup("bk-1", 3)], total: 1 });
|
||||
renderPage();
|
||||
expect(await screen.findByText("Latest backup")).toBeTruthy();
|
||||
expect(screen.queryByText(/^Page \d+ of/)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import { PhaseBadge } from "@/components/PhaseBadge";
|
||||
import { Loading, ErrorState, EmptyState, NotYours } from "@/components/States";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { Pagination } from "@/components/Pagination";
|
||||
import { api, humanizeError } from "@/lib/api";
|
||||
import { useAsync } from "@/lib/hooks";
|
||||
import { useTier } from "@/lib/tier";
|
||||
@@ -38,6 +39,8 @@ import { formatBytes, formatRelative, formatAbsolute, isExpired } from "@/lib/fo
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { BackupView, ServerJob } from "@/lib/types";
|
||||
|
||||
const BACKUP_PAGE_SIZE = 20;
|
||||
|
||||
const CELL = "whitespace-nowrap md:px-4 md:py-3.5";
|
||||
|
||||
/** BackupRow is one backup in the table, with its own restore action. `isLatest`
|
||||
@@ -453,7 +456,14 @@ export function ServerBackups() {
|
||||
() => (isAdmin ? Promise.resolve([]) : api.myServers()),
|
||||
[isAdmin, name],
|
||||
);
|
||||
const backupsQ = useAsync(() => api.listBackups(), []);
|
||||
// Only this server's backups, a page at a time; the API sorts them newest first.
|
||||
const [page, setPage] = useState(1);
|
||||
useEffect(() => setPage(1), [name]);
|
||||
const backupsQ = useAsync(
|
||||
() => api.listBackups({ server: name, limit: BACKUP_PAGE_SIZE, offset: (page - 1) * BACKUP_PAGE_SIZE }),
|
||||
[name, page],
|
||||
{ keepPrevious: true },
|
||||
);
|
||||
|
||||
// Ownership resolves from /me/servers for a non-admin (status carries no `owned`).
|
||||
// While it is pending show the header with a spinner rather than flashing the list
|
||||
@@ -536,13 +546,13 @@ export function ServerBackups() {
|
||||
|
||||
const now = Date.now();
|
||||
const locale = i18n.language;
|
||||
// The global list, narrowed to this server. Already created_at-descending from the
|
||||
// API, but re-sorted defensively; the newest backup that is not corrupt is the
|
||||
// one a restore with no pick recovers.
|
||||
const all = (backupsQ.data ?? [])
|
||||
.filter((b) => b.server_name === name)
|
||||
// This page of the server's backups. Already created_at-descending from the API,
|
||||
// but re-sorted defensively; the newest backup that is not corrupt is the one a
|
||||
// restore with no pick recovers, and it sits on the first page.
|
||||
const all = [...(backupsQ.data?.backups ?? [])]
|
||||
.sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at));
|
||||
const latestID = all.find((b) => !b.corrupt)?.id;
|
||||
const total = backupsQ.data?.total ?? 0;
|
||||
const latestID = page === 1 ? all.find((b) => !b.corrupt)?.id : undefined;
|
||||
|
||||
const header = (
|
||||
<PageHeader
|
||||
@@ -631,6 +641,10 @@ export function ServerBackups() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{total > BACKUP_PAGE_SIZE && (
|
||||
<Pagination page={page} pageSize={BACKUP_PAGE_SIZE} total={total} onChange={setPage} />
|
||||
)}
|
||||
|
||||
<Card className="border-dashed bg-transparent shadow-none">
|
||||
<CardContent className="p-4 text-xs text-muted-foreground">
|
||||
{t("history_note")}
|
||||
|
||||
@@ -140,7 +140,7 @@ describe("ImageBuildPage list", () => {
|
||||
close() {}
|
||||
},
|
||||
);
|
||||
calls.listSubmissions.mockResolvedValue([]);
|
||||
calls.listSubmissions.mockResolvedValue({ submissions: [], total: 0, counts: { pending_review: 0, approved: 0, rejected: 0 } });
|
||||
calls.listBuilds.mockResolvedValue(page([MINE], 25));
|
||||
const fresh: Build = { ...RUNNING, id: "b-new", image_ref: "registry.felis.svc:5000/fresh:1", requested_by: "[email protected]" };
|
||||
calls.buildImage.mockResolvedValue(fresh);
|
||||
|
||||
@@ -105,14 +105,16 @@ export function ImageBuildPage() {
|
||||
useEffect(() => {
|
||||
if (dialogOpen) {
|
||||
setLoadingSubmissions(true);
|
||||
api.listSubmissions()
|
||||
.then(setSubmissions)
|
||||
// The newest page is what a build is picked from; a non-owner only ever
|
||||
// sees approved rows, so the server narrows to those.
|
||||
api.listSubmissions({ status: isOwner ? undefined : "approved", limit: 100 })
|
||||
.then((p) => setSubmissions(p.submissions))
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
setLoadingSubmissions(false);
|
||||
});
|
||||
}
|
||||
}, [dialogOpen]);
|
||||
}, [dialogOpen, isOwner]);
|
||||
|
||||
const handleSelectSubmission = (subId: string) => {
|
||||
if (!subId || subId.startsWith("_")) return;
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import i18next from "i18next";
|
||||
import { SubmissionsPage } from "./SubmissionsPage";
|
||||
import type { Submission, SubmissionPage } from "@/lib/types";
|
||||
|
||||
const calls = vi.hoisted(() => ({
|
||||
listSubmissions: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/config", () => ({ loadConfig: () => Promise.resolve({}) }));
|
||||
vi.mock("@/lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/api")>();
|
||||
return { ...actual, api: { ...actual.api, ...calls } };
|
||||
});
|
||||
|
||||
const SUB: Submission = {
|
||||
id: "sub-7",
|
||||
submitted_by: "[email protected]",
|
||||
display_name: "Sky Block Pack",
|
||||
context_ref: "submissions/sub-7.tar.gz",
|
||||
status: "pending_review",
|
||||
created_at: new Date().toISOString(),
|
||||
context_sha256: "c".repeat(64),
|
||||
};
|
||||
|
||||
// One row of a 23-row queue: the rest live on the server's other pages.
|
||||
const PAGE: SubmissionPage = {
|
||||
submissions: [SUB],
|
||||
total: 23,
|
||||
counts: { pending_review: 5, approved: 17, rejected: 1 },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
calls.listSubmissions.mockReset();
|
||||
calls.listSubmissions.mockResolvedValue(PAGE);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
return i18next.changeLanguage("en-US");
|
||||
});
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<SubmissionsPage />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("SubmissionsPage", () => {
|
||||
it("counts the whole queue from the server's counts, whatever one page holds", async () => {
|
||||
renderPage();
|
||||
await screen.findByText("Sky Block Pack");
|
||||
expect(screen.getByRole("button", { name: "All (23)" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Pending Review (5)" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Approved (17)" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Rejected (1)" })).toBeTruthy();
|
||||
expect(screen.getByText("Page 1 of 3")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("asks the server for each page and filter, and a new filter or search starts at page one", async () => {
|
||||
renderPage();
|
||||
await screen.findByText("Sky Block Pack");
|
||||
expect(calls.listSubmissions).toHaveBeenLastCalledWith({ limit: 10, offset: 0 });
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||
await vi.waitFor(() => expect(calls.listSubmissions).toHaveBeenLastCalledWith({ limit: 10, offset: 10 }));
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Approved (17)" }));
|
||||
await vi.waitFor(() =>
|
||||
expect(calls.listSubmissions).toHaveBeenLastCalledWith({ status: "approved", limit: 10, offset: 0 }),
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||
await vi.waitFor(() =>
|
||||
expect(calls.listSubmissions).toHaveBeenLastCalledWith({ status: "approved", limit: 10, offset: 10 }),
|
||||
);
|
||||
await userEvent.type(screen.getByRole("textbox"), " sky ");
|
||||
await vi.waitFor(() =>
|
||||
expect(calls.listSubmissions).toHaveBeenLastCalledWith({ status: "approved", query: "sky", limit: 10, offset: 0 }),
|
||||
);
|
||||
});
|
||||
|
||||
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 }) =>
|
||||
offset === 20 ? { ...PAGE, submissions: [], total: 20 } : PAGE,
|
||||
);
|
||||
renderPage();
|
||||
await screen.findByText("Sky Block Pack");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||
await screen.findByText("Page 2 of 3");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||
await vi.waitFor(() => expect(calls.listSubmissions.mock.calls.map(([o]) => o.offset)).toEqual([0, 10, 20, 10]));
|
||||
expect(await screen.findByText("Sky Block Pack")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("says nothing matched when a filter empties the page, and hides the pager for one page", async () => {
|
||||
renderPage();
|
||||
await screen.findByText("Sky Block Pack");
|
||||
calls.listSubmissions.mockResolvedValue({ ...PAGE, submissions: [], total: 0 });
|
||||
await userEvent.click(screen.getByRole("button", { name: "Rejected (1)" }));
|
||||
expect(await screen.findByText("No matches")).toBeTruthy();
|
||||
expect(screen.queryByText(/^Page \d+ of/)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ClipboardCheck, CheckCircle2, CircleSlash, ChevronDown, ChevronUp, Check, X, Loader2, Download, Trash2, ShieldCheck, TriangleAlert } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
@@ -27,15 +27,13 @@ import { formatRelative, formatAbsolute } from "@/lib/format";
|
||||
import type { ApiError, Submission, SubmissionStatus } from "@/lib/types";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
export function SubmissionsPage() {
|
||||
const { t, i18n } = useTranslation("admin");
|
||||
const locale = i18n.language;
|
||||
const now = Date.now();
|
||||
|
||||
const { data, error, loading, reload } = useAsync(() => api.listSubmissions(), []);
|
||||
const submissions = useMemo<Submission[]>(() => data ?? [], [data]);
|
||||
|
||||
// Dialog State
|
||||
const [rejectDialogOpen, setRejectDialogOpen] = useState(false);
|
||||
const [rejectingId, setRejectingId] = useState<string | null>(null);
|
||||
@@ -54,56 +52,51 @@ export function SubmissionsPage() {
|
||||
// one stray click would take its uploaded context with it.
|
||||
const [confirmingDelete, setConfirmingDelete] = useState<string | null>(null);
|
||||
|
||||
// Search & Filtering State
|
||||
// Search, filter and paging all run on the server: the queue can hold any
|
||||
// number of uploads, so the page reads one screenful and the counts beside it.
|
||||
// The search goes out a moment after typing stops and starts from page one.
|
||||
const [search, setSearch] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | SubmissionStatus>("all");
|
||||
const [page, setPage] = useState(1);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setQuery(search.trim());
|
||||
setPage(1);
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search]);
|
||||
|
||||
// Stats
|
||||
const listSubmissions = useCallback(
|
||||
() =>
|
||||
api.listSubmissions({
|
||||
status: statusFilter === "all" ? undefined : statusFilter,
|
||||
query: query || undefined,
|
||||
limit: PAGE_SIZE,
|
||||
offset: (page - 1) * PAGE_SIZE,
|
||||
}),
|
||||
[statusFilter, query, page],
|
||||
);
|
||||
const { data, error, loading, reload } = useAsync(listSubmissions, [listSubmissions], { keepPrevious: true });
|
||||
const submissions = useMemo<Submission[]>(() => data?.submissions ?? [], [data]);
|
||||
const matching = data?.total ?? 0;
|
||||
|
||||
// The cards and filter chips count the whole queue, whatever is filtered.
|
||||
const stats = useMemo(() => {
|
||||
const total = submissions.length;
|
||||
const pending = submissions.filter((s) => s.status === "pending_review").length;
|
||||
const approved = submissions.filter((s) => s.status === "approved").length;
|
||||
const rejected = submissions.filter((s) => s.status === "rejected").length;
|
||||
return { total, pending, approved, rejected };
|
||||
}, [submissions]);
|
||||
|
||||
// Filtered & Paginated Submissions
|
||||
const filteredSubmissions = useMemo(() => {
|
||||
let list = [...submissions];
|
||||
|
||||
// 1. Search Filter
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
list = list.filter(
|
||||
(s) =>
|
||||
s.display_name.toLowerCase().includes(q) ||
|
||||
s.submitted_by.toLowerCase().includes(q) ||
|
||||
s.id.toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Status Filter
|
||||
if (statusFilter !== "all") {
|
||||
list = list.filter((s) => s.status === statusFilter);
|
||||
}
|
||||
|
||||
return list;
|
||||
}, [submissions, search, statusFilter]);
|
||||
|
||||
// Reset page when filter changes
|
||||
const lastFilterKey = `${search}-${statusFilter}`;
|
||||
const [prevFilterKey, setPrevFilterKey] = useState(lastFilterKey);
|
||||
if (prevFilterKey !== lastFilterKey) {
|
||||
setPage(1);
|
||||
setPrevFilterKey(lastFilterKey);
|
||||
}
|
||||
|
||||
const paginatedSubmissions = useMemo(() => {
|
||||
const start = (page - 1) * PAGE_SIZE;
|
||||
return filteredSubmissions.slice(start, start + PAGE_SIZE);
|
||||
}, [filteredSubmissions, page]);
|
||||
const c = data?.counts ?? { pending_review: 0, approved: 0, rejected: 0 };
|
||||
return {
|
||||
total: c.pending_review + c.approved + c.rejected,
|
||||
pending: c.pending_review,
|
||||
approved: c.approved,
|
||||
rejected: c.rejected,
|
||||
};
|
||||
}, [data]);
|
||||
const filtering = query !== "" || statusFilter !== "all";
|
||||
// A review or delete can empty the last page; step back to the one that now is.
|
||||
useEffect(() => {
|
||||
if (data && data.submissions.length === 0 && page > 1) setPage(Math.max(1, Math.ceil(data.total / PAGE_SIZE)));
|
||||
}, [data, page]);
|
||||
|
||||
async function handleApprove(sub: Submission) {
|
||||
const digest = reviewedDigests[sub.id] ?? sub.context_sha256;
|
||||
@@ -283,11 +276,11 @@ export function SubmissionsPage() {
|
||||
<div className="py-12"><Loading /></div>
|
||||
) : error ? (
|
||||
<div className="py-12"><ErrorState error={error} onRetry={reload} /></div>
|
||||
) : filteredSubmissions.length === 0 ? (
|
||||
) : submissions.length === 0 ? (
|
||||
<div className="p-4 border-b-0">
|
||||
<EmptyState
|
||||
title={search.trim() || statusFilter !== "all" ? t("search_no_results") : t("no_submissions_title")}
|
||||
hint={search.trim() || statusFilter !== "all" ? t("search_no_results_hint") : t("no_submissions_hint")}
|
||||
title={filtering ? t("search_no_results") : t("no_submissions_title")}
|
||||
hint={filtering ? t("search_no_results_hint") : t("no_submissions_hint")}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
@@ -303,7 +296,7 @@ export function SubmissionsPage() {
|
||||
|
||||
{/* Table Body */}
|
||||
<div className="divide-y divide-border select-text">
|
||||
{paginatedSubmissions.map((sub) => {
|
||||
{submissions.map((sub) => {
|
||||
const isExpanded = expandedId === sub.id;
|
||||
const isBusyApprove = busyId === sub.id && busyType === "approve";
|
||||
const isBusyReject = busyId === sub.id && busyType === "reject";
|
||||
@@ -511,12 +504,12 @@ export function SubmissionsPage() {
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{filteredSubmissions.length > PAGE_SIZE && (
|
||||
{matching > PAGE_SIZE && (
|
||||
<div className="p-4 border-t">
|
||||
<Pagination
|
||||
page={page}
|
||||
pageSize={PAGE_SIZE}
|
||||
total={filteredSubmissions.length}
|
||||
total={matching}
|
||||
onChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user