Loading panel/src/components/CreateServerDialog.test.tsx +69 −0 Changes for panel/src/components/CreateServerDialog.test.tsx: 69 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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(); }); }); panel/src/components/CreateServerDialog.tsx +26 −4 Changes for panel/src/components/CreateServerDialog.tsx: 26 added lines, 4 removed lines. Original line number Diff line number Diff line Loading @@ -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: "", Loading @@ -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; Loading Loading @@ -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" /> Loading Loading @@ -172,12 +184,14 @@ 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") : images.error ? t("create_server_image_unavailable") : enabledImages.length ? t("create_server_image_choose") : t("create_server_image_none") Loading @@ -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"> Loading panel/src/i18n/resources/en-US/servers.json +2 −0 Changes for panel/src/i18n/resources/en-US/servers.json: 2 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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", Loading panel/src/i18n/resources/zh-CN/servers.json +2 −0 Changes for panel/src/i18n/resources/zh-CN/servers.json: 2 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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": "存储", Loading Loading
panel/src/components/CreateServerDialog.test.tsx +69 −0 Changes for panel/src/components/CreateServerDialog.test.tsx: 69 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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(); }); });
panel/src/components/CreateServerDialog.tsx +26 −4 Changes for panel/src/components/CreateServerDialog.tsx: 26 added lines, 4 removed lines. Original line number Diff line number Diff line Loading @@ -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: "", Loading @@ -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; Loading Loading @@ -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" /> Loading Loading @@ -172,12 +184,14 @@ 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") : images.error ? t("create_server_image_unavailable") : enabledImages.length ? t("create_server_image_choose") : t("create_server_image_none") Loading @@ -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"> Loading
panel/src/i18n/resources/en-US/servers.json +2 −0 Changes for panel/src/i18n/resources/en-US/servers.json: 2 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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", Loading
panel/src/i18n/resources/zh-CN/servers.json +2 −0 Changes for panel/src/i18n/resources/zh-CN/servers.json: 2 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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": "存储", Loading