fix(panel): 构建对话框的提交选择器说明读取失败并可重试,服务端搜索找到最新 100 条之外的提交

This commit is contained in:
Lemon-miaow committed 2026-09-27 15:49:42 +08:00
1 parent ec861c10e0
commit d308a5edb1
4 files changed
+198 -28

No files matched your search

+6 -1
View File
@@ -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:", "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_label": "Import parameters from submission",
"build_import_submission_placeholder": "Select a user 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_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_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.", "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.",
+6 -1
View File
@@ -161,7 +161,12 @@
"versions_fix_hint": "下面的版本可能已过时。请先在主机上立即检查一次,再查看定时器日志找出它没有运行的原因:", "versions_fix_hint": "下面的版本可能已过时。请先在主机上立即检查一次,再查看定时器日志找出它没有运行的原因:",
"build_import_submission_label": "从已有的审核提交导入参数", "build_import_submission_label": "从已有的审核提交导入参数",
"build_import_submission_placeholder": "选择一个用户提交...", "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_hint": "选择提交将自动填充镜像引用、构建上下文引用和对应的 Dockerfile 审计头。",
"build_import_submission_warning_title": "警告:该提交状态为「{{status}}」", "build_import_submission_warning_title": "警告:该提交状态为「{{status}}」",
"build_import_submission_warning_desc": "手动触发构建不会自动将该提交标记为已同意,也不会更新其关联的构建状态。仅适用于紧急调试或测试。", "build_import_submission_warning_desc": "手动触发构建不会自动将该提交标记为已同意,也不会更新其关联的构建状态。仅适用于紧急调试或测试。",
+121 -2
View File
@@ -1,11 +1,12 @@
// @vitest-environment jsdom // @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 { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import i18next from "i18next"; import i18next from "i18next";
import { ImageBuildPage } from "./ImageBuildPage"; 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(() => ({ const calls = vi.hoisted(() => ({
listBuilds: vi.fn(), listBuilds: vi.fn(),
@@ -232,3 +233,121 @@ describe("ImageBuildPage cancel", () => {
expect((await within(dialog).findByRole("alert")).textContent).toBe("build already finished"); 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" }),
),
);
});
});
+65 -24
View File
@@ -35,6 +35,9 @@ import type { Build, BuildStatus, Submission } from "@/lib/types";
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
const POLL_MS = 4000; const POLL_MS = 4000;
const SEARCH_DEBOUNCE_MS = 300; 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"; const isActive = (b: Build) => b.status === "pending" || b.status === "building";
@@ -92,34 +95,42 @@ export function ImageBuildPage() {
const [triggering, setTriggering] = useState(false); const [triggering, setTriggering] = useState(false);
const [triggerError, setTriggerError] = useState<string | null>(null); const [triggerError, setTriggerError] = useState<string | null>(null);
const [submissions, setSubmissions] = useState<Submission[]>([]); // The submission picker reads while the dialog is open. Its search runs on
const [loadingSubmissions, setLoadingSubmissions] = useState(false); // 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<Submission | null>(null); const [selectedSub, setSelectedSub] = useState<Submission | null>(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(() => { const visibleSubmissions = useMemo(() => {
if (isOwner) { const shown = isOwner ? submissions : submissions.filter((s) => s.status === "approved");
return submissions; return selectedSub && !shown.some((s) => s.id === selectedSub.id) ? [selectedSub, ...shown] : shown;
} }, [submissions, isOwner, selectedSub]);
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 handleSelectSubmission = (subId: string) => { const handleSelectSubmission = (subId: string) => {
if (!subId || subId.startsWith("_")) return; if (!subId || subId.startsWith("_")) return;
const sub = submissions.find((s) => s.id === subId); const sub = visibleSubmissions.find((s) => s.id === subId);
if (!sub) return; if (!sub) return;
setSelectedSub(sub); setSelectedSub(sub);
@@ -229,6 +240,8 @@ export function ImageBuildPage() {
if (!o) { if (!o) {
setTriggerError(null); setTriggerError(null);
setSelectedSub(null); setSelectedSub(null);
setSubSearch("");
setSubQuery("");
} }
}}> }}>
<DialogTrigger asChild> <DialogTrigger asChild>
@@ -248,18 +261,30 @@ export function ImageBuildPage() {
<Label htmlFor="build-import-submission" className="text-xs font-semibold text-muted-foreground"> <Label htmlFor="build-import-submission" className="text-xs font-semibold text-muted-foreground">
{t("build_import_submission_label")} {t("build_import_submission_label")}
</Label> </Label>
<SearchInput
value={subSearch}
onChange={setSubSearch}
placeholder={t("build_import_search_placeholder")}
className="min-w-0"
/>
<Select onValueChange={handleSelectSubmission} disabled={triggering}> <Select onValueChange={handleSelectSubmission} disabled={triggering}>
<SelectTrigger id="build-import-submission" className="w-full text-xs h-9 bg-background [&>span]:flex [&>span]:w-full [&>span]:items-center [&>span]:justify-between [&>span]:gap-2 pr-2"> <SelectTrigger id="build-import-submission" className="w-full text-xs h-9 bg-background [&>span]:flex [&>span]:w-full [&>span]:items-center [&>span]:justify-between [&>span]:gap-2 pr-2">
<SelectValue placeholder={t("build_import_submission_placeholder")} /> <SelectValue placeholder={t("build_import_submission_placeholder")} />
</SelectTrigger> </SelectTrigger>
<SelectContent className="max-h-60 overflow-y-auto"> <SelectContent className="max-h-60 overflow-y-auto">
{loadingSubmissions ? ( {subsQ.loading && !subsQ.data ? (
<SelectItem value="_loading" disabled> <SelectItem value="_loading" disabled>
{t("common:loading_config")}... {t("common:loading_config")}...
</SelectItem> </SelectItem>
) : subsQ.error && !subsQ.data ? (
<SelectItem value="_failed" disabled>
{t("build_import_submission_unavailable")}
</SelectItem>
) : visibleSubmissions.length === 0 ? ( ) : visibleSubmissions.length === 0 ? (
<SelectItem value="_none" disabled> <SelectItem value="_none" disabled>
{t("build_import_submission_none")} {subQuery
? t("build_import_submission_no_match", { query: subQuery })
: t("build_import_submission_none")}
</SelectItem> </SelectItem>
) : ( ) : (
visibleSubmissions.map((sub) => ( visibleSubmissions.map((sub) => (
@@ -287,6 +312,22 @@ export function ImageBuildPage() {
)} )}
</SelectContent> </SelectContent>
</Select> </Select>
{!!subsQ.error && (
<div role="alert" className="flex flex-wrap items-center justify-between gap-2 text-xs text-destructive">
<span>{t("build_import_submission_failed", { reason: humanizeError(subsQ.error) })}</span>
<Button type="button" size="sm" variant="outline" className="h-7" onClick={subsQ.reload}>
{t("common:try_again")}
</Button>
</div>
)}
{!!subsQ.data && subsQ.data.total > subsQ.data.submissions.length && (
<p className="text-[10px] text-muted-foreground leading-normal">
{t("build_import_submission_more", {
shown: subsQ.data.submissions.length,
total: subsQ.data.total,
})}
</p>
)}
<p className="text-[10px] text-muted-foreground/80 leading-normal"> <p className="text-[10px] text-muted-foreground/80 leading-normal">
{t("build_import_submission_hint")} {t("build_import_submission_hint")}
</p> </p>