diff --git a/panel/src/i18n/resources/en-US/admin.json b/panel/src/i18n/resources/en-US/admin.json index 0fee256..b96d01e 100644 --- a/panel/src/i18n/resources/en-US/admin.json +++ b/panel/src/i18n/resources/en-US/admin.json @@ -162,7 +162,12 @@ "versions_fix_hint": "The versions below may be out of date. Run a check on the host now, then read the timer's log to find out why it did not run:", "build_import_submission_label": "Import parameters from submission", "build_import_submission_placeholder": "Select a user submission...", - "build_import_submission_none": "No matching submissions found or not loaded", + "build_import_submission_none": "No submissions to import yet", + "build_import_submission_no_match": "No submissions match “{{query}}”", + "build_import_submission_unavailable": "Submission list not loaded", + "build_import_submission_failed": "Couldn't load submissions: {{reason}}", + "build_import_submission_more": "Listing the newest {{shown}} of {{total}}; search above to find older ones.", + "build_import_search_placeholder": "Search submissions by name, ID or submitter", "build_import_submission_hint": "Selecting a submission automatically populates the Image Reference, Context Reference, and the corresponding Dockerfile audit header.", "build_import_submission_warning_title": "Warning: This submission is currently \"{{status}}\"", "build_import_submission_warning_desc": "Manually triggering a build will not automatically mark this submission as approved, nor will it update its associated build status in the database. Use for emergency debugging or testing only.", diff --git a/panel/src/i18n/resources/zh-CN/admin.json b/panel/src/i18n/resources/zh-CN/admin.json index 5f7235a..8726fc1 100644 --- a/panel/src/i18n/resources/zh-CN/admin.json +++ b/panel/src/i18n/resources/zh-CN/admin.json @@ -161,7 +161,12 @@ "versions_fix_hint": "下面的版本可能已过时。请先在主机上立即检查一次,再查看定时器日志找出它没有运行的原因:", "build_import_submission_label": "从已有的审核提交导入参数", "build_import_submission_placeholder": "选择一个用户提交...", - "build_import_submission_none": "无匹配的提交或暂未加载", + "build_import_submission_none": "还没有可导入的提交", + "build_import_submission_no_match": "没有匹配“{{query}}”的提交", + "build_import_submission_unavailable": "提交列表未读到", + "build_import_submission_failed": "提交列表读取失败:{{reason}}", + "build_import_submission_more": "列出最新的 {{shown}} 条,共 {{total}} 条;更早的提交用上面的搜索找。", + "build_import_search_placeholder": "按名称、ID 或提交人搜索提交", "build_import_submission_hint": "选择提交将自动填充镜像引用、构建上下文引用和对应的 Dockerfile 审计头。", "build_import_submission_warning_title": "警告:该提交状态为「{{status}}」", "build_import_submission_warning_desc": "手动触发构建不会自动将该提交标记为已同意,也不会更新其关联的构建状态。仅适用于紧急调试或测试。", diff --git a/panel/src/pages/admin/ImageBuildPage.test.tsx b/panel/src/pages/admin/ImageBuildPage.test.tsx index 2efe9c8..368003d 100644 --- a/panel/src/pages/admin/ImageBuildPage.test.tsx +++ b/panel/src/pages/admin/ImageBuildPage.test.tsx @@ -1,11 +1,12 @@ // @vitest-environment jsdom -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest"; import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; import i18next from "i18next"; import { ImageBuildPage } from "./ImageBuildPage"; -import type { Build } from "@/lib/types"; +import { humanizeError } from "@/lib/api"; +import type { Build, Submission, SubmissionPage } from "@/lib/types"; const calls = vi.hoisted(() => ({ listBuilds: vi.fn(), @@ -232,3 +233,121 @@ describe("ImageBuildPage cancel", () => { expect((await within(dialog).findByRole("alert")).textContent).toBe("build already finished"); }); }); + +describe("ImageBuildPage submission picker", () => { + // Radix Select measures and captures the pointer, which jsdom leaves out. + beforeAll(() => { + Element.prototype.hasPointerCapture ??= () => false; + Element.prototype.releasePointerCapture ??= () => {}; + Element.prototype.scrollIntoView ??= () => {}; + }); + + const sub = (id: string, display_name: string): Submission => ({ + id, + submitted_by: "user-1", + display_name, + context_ref: `submissions/${id}.tar.gz`, + status: "approved", + created_at: "2026-09-01T00:00:00Z", + }); + const subPage = (subs: Submission[], total = subs.length): SubmissionPage => ({ + submissions: subs, + total, + counts: { pending_review: 0, approved: total, rejected: 0 }, + }); + const NEWEST = sub("sub-250", "Newest Pack"); + const ANCIENT = sub("sub-3", "Ancient Pack"); + + async function openDialog() { + const user = userEvent.setup(); + renderPage(); + await user.click(await screen.findByRole("button", { name: t("admin:trigger_build_title") })); + const dialog = screen.getByRole("dialog", { name: t("admin:trigger_build_title") }); + return { user, dialog }; + } + const picker = () => screen.getByRole("combobox", { name: t("admin:build_import_submission_label") }); + const search = (dialog: HTMLElement) => within(dialog).getByPlaceholderText(t("admin:build_import_search_placeholder")); + + it("says why the submissions did not load and reads them again", async () => { + const outage = { status: 503, code: "unavailable", message: "db down" }; + calls.listSubmissions.mockRejectedValueOnce(outage).mockResolvedValue(subPage([NEWEST])); + const { user, dialog } = await openDialog(); + + const alert = await within(dialog).findByRole("alert"); + expect(alert.textContent).toBe( + i18next.t("admin:build_import_submission_failed", { reason: humanizeError(outage) }) + t("common:try_again"), + ); + await user.click(picker()); + expect((await screen.findByRole("option")).textContent).toBe(t("admin:build_import_submission_unavailable")); + await user.keyboard("{Escape}"); + + await user.click(within(alert).getByRole("button", { name: t("common:try_again") })); + await vi.waitFor(() => expect(within(dialog).queryByRole("alert")).toBeNull()); + await user.click(picker()); + expect(await screen.findByRole("option", { name: /Newest Pack/ })).toBeTruthy(); + }); + + it("searches the server for a submission older than the newest page", async () => { + calls.listSubmissions.mockResolvedValue(subPage([NEWEST], 250)); + const { user, dialog } = await openDialog(); + const more = i18next.t("admin:build_import_submission_more", { shown: 1, total: 250 }); + expect(await within(dialog).findByText(more)).toBeTruthy(); + expect(calls.listSubmissions).toHaveBeenLastCalledWith({ status: undefined, query: undefined, limit: 100 }); + + calls.listSubmissions.mockResolvedValue(subPage([ANCIENT])); + await user.type(search(dialog), "ancient"); + await vi.waitFor(() => + expect(calls.listSubmissions).toHaveBeenLastCalledWith({ status: undefined, query: "ancient", limit: 100 }), + ); + await vi.waitFor(() => expect(within(dialog).queryByText(more)).toBeNull()); + expect(within(dialog).queryByText(/Listing the newest/)).toBeNull(); + await user.click(picker()); + await user.click(await screen.findByRole("option", { name: /Ancient Pack/ })); + expect((within(dialog).getByLabelText(new RegExp(t("admin:context_ref_label"))) as HTMLInputElement).value).toBe( + "submissions/sub-3.tar.gz", + ); + + // Closed and opened again, the picker starts over from the newest page. + calls.listSubmissions.mockResolvedValue(subPage([NEWEST], 250)); + await user.keyboard("{Escape}"); + await user.click(screen.getByRole("button", { name: t("admin:trigger_build_title") })); + const again = screen.getByRole("dialog", { name: t("admin:trigger_build_title") }); + expect((search(again) as HTMLInputElement).value).toBe(""); + expect(await within(again).findByText(more)).toBeTruthy(); + expect(calls.listSubmissions).toHaveBeenLastCalledWith({ status: undefined, query: undefined, limit: 100 }); + }); + + it("keeps naming the picked submission while a search narrows past it", async () => { + calls.listSubmissions.mockResolvedValue(subPage([NEWEST, ANCIENT])); + const { user, dialog } = await openDialog(); + await user.click(picker()); + await user.click(await screen.findByRole("option", { name: /Ancient Pack/ })); + expect(picker().textContent).toContain("Ancient Pack"); + + calls.listSubmissions.mockResolvedValue(subPage([NEWEST])); + await user.type(search(dialog), "newest"); + await vi.waitFor(() => expect(calls.listSubmissions).toHaveBeenLastCalledWith(expect.objectContaining({ query: "newest" }))); + // Open, the list hides the rest of the dialog from the accessibility tree. + const trigger = picker(); + await user.click(trigger); + await screen.findByRole("option", { name: /Newest Pack/ }); + expect(trigger.textContent).toContain("Ancient Pack"); + expect(screen.getAllByRole("option").map((o) => o.textContent?.includes("Ancient Pack"))).toEqual([true, false]); + }); + + it("names a search that found nothing", async () => { + calls.listSubmissions.mockResolvedValue(subPage([NEWEST])); + const { user, dialog } = await openDialog(); + await within(dialog).findByText(t("admin:build_import_submission_hint")); + + calls.listSubmissions.mockResolvedValue(subPage([])); + await user.type(search(dialog), "zzz"); + await vi.waitFor(() => expect(calls.listSubmissions).toHaveBeenLastCalledWith(expect.objectContaining({ query: "zzz" }))); + await user.click(picker()); + await vi.waitFor(async () => + expect((await screen.findByRole("option")).textContent).toBe( + i18next.t("admin:build_import_submission_no_match", { query: "zzz" }), + ), + ); + }); +}); diff --git a/panel/src/pages/admin/ImageBuildPage.tsx b/panel/src/pages/admin/ImageBuildPage.tsx index 20d8167..cef151a 100644 --- a/panel/src/pages/admin/ImageBuildPage.tsx +++ b/panel/src/pages/admin/ImageBuildPage.tsx @@ -35,6 +35,9 @@ import type { Build, BuildStatus, Submission } from "@/lib/types"; const PAGE_SIZE = 10; const POLL_MS = 4000; const SEARCH_DEBOUNCE_MS = 300; +// The server caps a page of submissions at 100 (submit.MaxListLimit); the picker +// shows that newest page and searches the server for anything older. +const SUBMISSION_PICK_LIMIT = 100; const isActive = (b: Build) => b.status === "pending" || b.status === "building"; @@ -92,34 +95,42 @@ export function ImageBuildPage() { const [triggering, setTriggering] = useState(false); const [triggerError, setTriggerError] = useState(null); - const [submissions, setSubmissions] = useState([]); - const [loadingSubmissions, setLoadingSubmissions] = useState(false); + // The submission picker reads while the dialog is open. Its search runs on + // the server (id, submitter or name), so a submission older than the newest + // page is still one query away, and a failed read says why instead of + // passing for an empty list. + const [subSearch, setSubSearch] = useState(""); + const [subQuery, setSubQuery] = useState(""); + useEffect(() => { + const timer = setTimeout(() => setSubQuery(subSearch.trim()), SEARCH_DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [subSearch]); + const subsQ = useAsync( + () => + dialogOpen + ? // A non-owner only ever sees approved rows, so the server narrows to those. + api.listSubmissions({ + status: isOwner ? undefined : "approved", + query: subQuery || undefined, + limit: SUBMISSION_PICK_LIMIT, + }) + : Promise.resolve(null), + [dialogOpen, isOwner, subQuery], + { keepPrevious: true }, + ); + const submissions = useMemo(() => subsQ.data?.submissions ?? [], [subsQ.data]); const [selectedSub, setSelectedSub] = useState(null); + // The picked submission stays in the list while a search narrows past it, so + // the picker keeps naming what filled the form. const visibleSubmissions = useMemo(() => { - if (isOwner) { - return submissions; - } - return submissions.filter((s) => s.status === "approved"); - }, [submissions, isOwner]); - - useEffect(() => { - if (dialogOpen) { - setLoadingSubmissions(true); - // 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, isOwner]); + const shown = isOwner ? submissions : submissions.filter((s) => s.status === "approved"); + return selectedSub && !shown.some((s) => s.id === selectedSub.id) ? [selectedSub, ...shown] : shown; + }, [submissions, isOwner, selectedSub]); const handleSelectSubmission = (subId: string) => { if (!subId || subId.startsWith("_")) return; - const sub = submissions.find((s) => s.id === subId); + const sub = visibleSubmissions.find((s) => s.id === subId); if (!sub) return; setSelectedSub(sub); @@ -229,6 +240,8 @@ export function ImageBuildPage() { if (!o) { setTriggerError(null); setSelectedSub(null); + setSubSearch(""); + setSubQuery(""); } }}> @@ -248,18 +261,30 @@ export function ImageBuildPage() { + + {!!subsQ.error && ( +
+ {t("build_import_submission_failed", { reason: humanizeError(subsQ.error) })} + +
+ )} + {!!subsQ.data && subsQ.data.total > subsQ.data.submissions.length && ( +

+ {t("build_import_submission_more", { + shown: subsQ.data.submissions.length, + total: subsQ.data.total, + })} +

+ )}

{t("build_import_submission_hint")}