fix(panel): 建服对话框每次打开重读镜像白名单,编辑或重开时清掉旧错误,读不到白名单时说明并可重试
This commit is contained in:
4 files changed
+99
-4
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();
|
||||
});
|
||||
});
|
||||
@@ -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,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")
|
||||
@@ -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": "存储",
|
||||
|
||||
Reference in new issue
Block a user