fix(panel): 建服对话框每次打开重读镜像白名单,编辑或重开时清掉旧错误,读不到白名单时说明并可重试

This commit is contained in:
Lemon-miaow committed 2026-09-27 15:28:45 +08:00
1 parent c1bf1a81a2
commit 9df1178022
4 files changed
+102 -7

No files matched your search

@@ -189,3 +189,72 @@ describe("CreateServerDialog names", () => {
expect(subdomainBox().getAttribute("aria-invalid")).toBe("false");
});
});
describe("CreateServerDialog on reopening", () => {
const cancel = () => screen.getByRole("button", { name: "Cancel" });
const reopen = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(screen.getByRole("button", { name: "New server" }));
await screen.findByRole("dialog");
};
it("rereads the image whitelist each time it opens", async () => {
const NEW = "registry.example.test/fabric:1.21";
const user = await openDialog();
await user.click(cancel());
expect(calls.listImages).toHaveBeenCalledTimes(1);
calls.listImages.mockResolvedValue([image(PAPER, true), image(NEW, true)]);
await reopen(user);
expect(calls.listImages).toHaveBeenCalledTimes(2);
await user.click(screen.getByRole("combobox", { name: "Image" }));
const options = await screen.findAllByRole("option");
expect(options.map((o) => o.textContent)).toEqual([PAPER, NEW]);
});
it("drops a chosen image that was disabled while the dialog was closed", async () => {
const user = await openDialog();
await fillValid(user);
expect(create().disabled).toBe(false);
await user.click(cancel());
calls.listImages.mockResolvedValue([image(PAPER, false)]);
await reopen(user);
expect(await screen.findByText("No whitelisted images")).toBeTruthy();
expect(nameBox().value).toBe("survival");
expect(create().disabled).toBe(true);
});
it("clears a refusal once the form is edited, and when the dialog opens again", async () => {
const refusal = { status: 409, code: "conflict", message: "server survival already exists" };
calls.createServer.mockRejectedValue(refusal);
const user = await openDialog();
await fillValid(user);
await user.click(create());
expect(screen.getByText(humanizeError(refusal))).toBeTruthy();
await user.type(nameBox(), "-2");
expect(screen.queryByText(humanizeError(refusal))).toBeNull();
await user.click(create());
expect(screen.getByText(humanizeError(refusal))).toBeTruthy();
await user.click(cancel());
await reopen(user);
expect(screen.queryByText(humanizeError(refusal))).toBeNull();
});
it("says when the whitelist could not be read, and reads it again on request", async () => {
const outage = { status: 503, code: "unavailable", message: "images unavailable" };
calls.listImages.mockRejectedValueOnce(outage);
const user = await openDialog();
const alert = await screen.findByRole("alert");
expect(alert.textContent).toContain(humanizeError(outage));
expect(screen.getByText("Image list not loaded")).toBeTruthy();
expect(screen.queryByText("No whitelisted images")).toBeNull();
await user.click(within(alert).getByRole("button", { name: "Try again" }));
expect(calls.listImages).toHaveBeenCalledTimes(2);
await pick(user, "Image", PAPER);
expect(screen.queryByRole("alert")).toBeNull();
});
});
+29 -7
View File
@@ -52,7 +52,9 @@ interface Props {
export function CreateServerDialog({ cfg, onCreated }: Props) {
const { t } = useTranslation("servers");
const [open, setOpen] = useState(false);
const images = useAsync(() => api.listImages(), []);
// The whitelist is read each time the dialog opens, so an image added, enabled
// or retired since the page loaded shows up or drops out without a reload.
const images = useAsync(() => (open ? api.listImages() : Promise.resolve(null)), [open]);
const [form, setForm] = useState<CreateServerRequest>({
name: "",
@@ -66,17 +68,27 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
// A refusal answers the form as it was sent; any edit, or opening the dialog
// again, starts past it.
function set<K extends keyof CreateServerRequest>(k: K, v: CreateServerRequest[K]) {
setForm((f) => ({ ...f, [k]: v }));
setError(null);
}
function openChange(next: boolean) {
setOpen(next);
if (next) setError(null);
}
const enabledImages = (images.data ?? []).filter((i) => i.enabled);
// An image chosen before it was disabled or removed no longer counts as chosen.
const image = enabledImages.some((i) => i.image_ref === form.image) ? form.image : "";
const nameIssue = serverNameIssue(form.name);
const subdomainIssue = serverNameIssue(form.subdomain);
const canSubmit =
nameIssue === null &&
subdomainIssue === null &&
!!form.image &&
!!image &&
!!form.memory &&
!!form.storage &&
!submitting;
@@ -109,7 +121,7 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
value && issue ? "text-xs text-destructive" : "text-xs text-muted-foreground";
return (
<Dialog open={open} onOpenChange={setOpen}>
<Dialog open={open} onOpenChange={openChange}>
<DialogTrigger asChild>
<Button size="sm" className="gap-1.5">
<Plus className="h-4 w-4" />
@@ -172,15 +184,17 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
<div className="grid gap-2">
<Label htmlFor="create-server-image">{t("create_server_image")}</Label>
<Select value={form.image} onValueChange={(v) => set("image", v)}>
<Select value={image} onValueChange={(v) => set("image", v)}>
<SelectTrigger id="create-server-image">
<SelectValue
placeholder={
images.loading
? t("create_server_image_loading")
: enabledImages.length
? t("create_server_image_choose")
: t("create_server_image_none")
: images.error
? t("create_server_image_unavailable")
: enabledImages.length
? t("create_server_image_choose")
: t("create_server_image_none")
}
/>
</SelectTrigger>
@@ -192,6 +206,14 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
))}
</SelectContent>
</Select>
{!!images.error && (
<div role="alert" className="flex items-center justify-between gap-3 text-xs text-destructive">
<span>{t("create_server_image_failed", { reason: humanizeError(images.error) })}</span>
<Button variant="outline" size="sm" className="shrink-0" onClick={images.reload}>
{t("common:try_again")}
</Button>
</div>
)}
</div>
<div className="grid grid-cols-2 gap-4">
@@ -144,6 +144,8 @@
"create_server_image": "Image",
"create_server_image_loading": "Loading images…",
"create_server_image_choose": "Choose an image",
"create_server_image_unavailable": "Image list not loaded",
"create_server_image_failed": "Couldn't load the image list: {{reason}}",
"create_server_image_none": "No whitelisted images",
"create_server_memory": "Memory",
"create_server_storage": "Storage",
@@ -144,6 +144,8 @@
"create_server_image": "镜像",
"create_server_image_loading": "加载镜像列表中…",
"create_server_image_choose": "选择一个镜像",
"create_server_image_unavailable": "镜像列表未读到",
"create_server_image_failed": "镜像列表读取失败:{{reason}}",
"create_server_image_none": "暂无白名单镜像",
"create_server_memory": "内存",
"create_server_storage": "存储",