fix(panel): 构建对话框的提交选择器说明读取失败并可重试,服务端搜索找到最新 100 条之外的提交
This commit is contained in:
4 files changed
+198
-28
No files matched your search
@@ -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.",
|
||||
|
||||
@@ -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": "手动触发构建不会自动将该提交标记为已同意,也不会更新其关联的构建状态。仅适用于紧急调试或测试。",
|
||||
|
||||
@@ -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" }),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<string | null>(null);
|
||||
|
||||
const [submissions, setSubmissions] = useState<Submission[]>([]);
|
||||
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<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(() => {
|
||||
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("");
|
||||
}
|
||||
}}>
|
||||
<DialogTrigger asChild>
|
||||
@@ -248,18 +261,30 @@ export function ImageBuildPage() {
|
||||
<Label htmlFor="build-import-submission" className="text-xs font-semibold text-muted-foreground">
|
||||
{t("build_import_submission_label")}
|
||||
</Label>
|
||||
<SearchInput
|
||||
value={subSearch}
|
||||
onChange={setSubSearch}
|
||||
placeholder={t("build_import_search_placeholder")}
|
||||
className="min-w-0"
|
||||
/>
|
||||
<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">
|
||||
<SelectValue placeholder={t("build_import_submission_placeholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-60 overflow-y-auto">
|
||||
{loadingSubmissions ? (
|
||||
{subsQ.loading && !subsQ.data ? (
|
||||
<SelectItem value="_loading" disabled>
|
||||
{t("common:loading_config")}...
|
||||
</SelectItem>
|
||||
) : subsQ.error && !subsQ.data ? (
|
||||
<SelectItem value="_failed" disabled>
|
||||
{t("build_import_submission_unavailable")}
|
||||
</SelectItem>
|
||||
) : visibleSubmissions.length === 0 ? (
|
||||
<SelectItem value="_none" disabled>
|
||||
{t("build_import_submission_none")}
|
||||
{subQuery
|
||||
? t("build_import_submission_no_match", { query: subQuery })
|
||||
: t("build_import_submission_none")}
|
||||
</SelectItem>
|
||||
) : (
|
||||
visibleSubmissions.map((sub) => (
|
||||
@@ -287,6 +312,22 @@ export function ImageBuildPage() {
|
||||
)}
|
||||
</SelectContent>
|
||||
</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">
|
||||
{t("build_import_submission_hint")}
|
||||
</p>
|
||||
|
||||
Reference in new issue
Block a user