fix(panel): 建服对话框每次打开重读镜像白名单,编辑或重开时清掉旧错误,读不到白名单时说明并可重试
This commit is contained in:
4 files changed
+102
-7
No files matched your search
@@ -189,3 +189,72 @@ describe("CreateServerDialog names", () => {
|
|||||||
expect(subdomainBox().getAttribute("aria-invalid")).toBe("false");
|
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) {
|
export function CreateServerDialog({ cfg, onCreated }: Props) {
|
||||||
const { t } = useTranslation("servers");
|
const { t } = useTranslation("servers");
|
||||||
const [open, setOpen] = useState(false);
|
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>({
|
const [form, setForm] = useState<CreateServerRequest>({
|
||||||
name: "",
|
name: "",
|
||||||
@@ -66,17 +68,27 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
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]) {
|
function set<K extends keyof CreateServerRequest>(k: K, v: CreateServerRequest[K]) {
|
||||||
setForm((f) => ({ ...f, [k]: v }));
|
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);
|
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 nameIssue = serverNameIssue(form.name);
|
||||||
const subdomainIssue = serverNameIssue(form.subdomain);
|
const subdomainIssue = serverNameIssue(form.subdomain);
|
||||||
const canSubmit =
|
const canSubmit =
|
||||||
nameIssue === null &&
|
nameIssue === null &&
|
||||||
subdomainIssue === null &&
|
subdomainIssue === null &&
|
||||||
!!form.image &&
|
!!image &&
|
||||||
!!form.memory &&
|
!!form.memory &&
|
||||||
!!form.storage &&
|
!!form.storage &&
|
||||||
!submitting;
|
!submitting;
|
||||||
@@ -109,7 +121,7 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
|
|||||||
value && issue ? "text-xs text-destructive" : "text-xs text-muted-foreground";
|
value && issue ? "text-xs text-destructive" : "text-xs text-muted-foreground";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={openChange}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button size="sm" className="gap-1.5">
|
<Button size="sm" className="gap-1.5">
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
@@ -172,15 +184,17 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
|
|||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="create-server-image">{t("create_server_image")}</Label>
|
<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">
|
<SelectTrigger id="create-server-image">
|
||||||
<SelectValue
|
<SelectValue
|
||||||
placeholder={
|
placeholder={
|
||||||
images.loading
|
images.loading
|
||||||
? t("create_server_image_loading")
|
? t("create_server_image_loading")
|
||||||
: enabledImages.length
|
: images.error
|
||||||
? t("create_server_image_choose")
|
? t("create_server_image_unavailable")
|
||||||
: t("create_server_image_none")
|
: enabledImages.length
|
||||||
|
? t("create_server_image_choose")
|
||||||
|
: t("create_server_image_none")
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -192,6 +206,14 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
|||||||
@@ -144,6 +144,8 @@
|
|||||||
"create_server_image": "Image",
|
"create_server_image": "Image",
|
||||||
"create_server_image_loading": "Loading images…",
|
"create_server_image_loading": "Loading images…",
|
||||||
"create_server_image_choose": "Choose an image",
|
"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_image_none": "No whitelisted images",
|
||||||
"create_server_memory": "Memory",
|
"create_server_memory": "Memory",
|
||||||
"create_server_storage": "Storage",
|
"create_server_storage": "Storage",
|
||||||
|
|||||||
@@ -144,6 +144,8 @@
|
|||||||
"create_server_image": "镜像",
|
"create_server_image": "镜像",
|
||||||
"create_server_image_loading": "加载镜像列表中…",
|
"create_server_image_loading": "加载镜像列表中…",
|
||||||
"create_server_image_choose": "选择一个镜像",
|
"create_server_image_choose": "选择一个镜像",
|
||||||
|
"create_server_image_unavailable": "镜像列表未读到",
|
||||||
|
"create_server_image_failed": "镜像列表读取失败:{{reason}}",
|
||||||
"create_server_image_none": "暂无白名单镜像",
|
"create_server_image_none": "暂无白名单镜像",
|
||||||
"create_server_memory": "内存",
|
"create_server_memory": "内存",
|
||||||
"create_server_storage": "存储",
|
"create_server_storage": "存储",
|
||||||
|
|||||||
Reference in new issue
Block a user