diff --git a/panel/src/components/CreateServerDialog.test.tsx b/panel/src/components/CreateServerDialog.test.tsx index 0305211..3a15f90 100644 --- a/panel/src/components/CreateServerDialog.test.tsx +++ b/panel/src/components/CreateServerDialog.test.tsx @@ -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) => { + 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(); + }); +}); diff --git a/panel/src/components/CreateServerDialog.tsx b/panel/src/components/CreateServerDialog.tsx index 36eb373..33d2da8 100644 --- a/panel/src/components/CreateServerDialog.tsx +++ b/panel/src/components/CreateServerDialog.tsx @@ -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({ name: "", @@ -66,17 +68,27 @@ export function CreateServerDialog({ cfg, onCreated }: Props) { const [error, setError] = useState(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: 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 ( - + + + )}
diff --git a/panel/src/i18n/resources/en-US/servers.json b/panel/src/i18n/resources/en-US/servers.json index 300fee0..ce034be 100644 --- a/panel/src/i18n/resources/en-US/servers.json +++ b/panel/src/i18n/resources/en-US/servers.json @@ -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", diff --git a/panel/src/i18n/resources/zh-CN/servers.json b/panel/src/i18n/resources/zh-CN/servers.json index efca840..6aee85a 100644 --- a/panel/src/i18n/resources/zh-CN/servers.json +++ b/panel/src/i18n/resources/zh-CN/servers.json @@ -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": "存储",