From 2e2d6f574628411dbe6b9ea3cd17bb347b10a4b0 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 3 Oct 2026 21:53:06 +0200 Subject: [PATCH] fix(downloads): the quota editor opens at once and says it is loading or failed It rendered nothing until its quota arrived and nothing at all when the load failed, so the click looked dead. The first field still takes focus when the form arrives. --- .../src/components/downloadAdmin.dom.test.tsx | 75 +++++++++++ frontend/src/components/downloadAdmin.tsx | 124 ++++++++++-------- 2 files changed, 145 insertions(+), 54 deletions(-) create mode 100644 frontend/src/components/downloadAdmin.dom.test.tsx diff --git a/frontend/src/components/downloadAdmin.dom.test.tsx b/frontend/src/components/downloadAdmin.dom.test.tsx new file mode 100644 index 00000000..1fcd3a56 --- /dev/null +++ b/frontend/src/components/downloadAdmin.dom.test.tsx @@ -0,0 +1,75 @@ +// @vitest-environment jsdom +// +// E32 S2: the admin's download-quota editor rendered NOTHING while its quota loaded and nothing when +// the load failed, so the click looked dead. It now opens at once and says which it is. The save +// turns the GB field back into bytes; the reset drops the override. +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { api } from "../lib/api"; +import { QuotaModal } from "./downloadAdmin"; + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), + }; +}); + +const GiB = 1024 ** 3; +const QUOTA = { max_bytes: 5 * GiB, max_jobs: 10, max_concurrent: 2, unlimited: false }; +let qc: QueryClient; +const wrap = (ui: ReactNode) => {ui}; +const open = (onClose = () => {}) => + render(wrap()); + +beforeEach(() => { + qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe("the download-quota editor", () => { + it("opens at once and says it is loading", () => { + vi.spyOn(api, "adminGetDownloadQuota").mockReturnValue(new Promise(() => {}) as never); + open(); + expect(screen.getByText("downloads.admin.quotaTitle")).toBeTruthy(); + expect(screen.getAllByRole("status").length).toBeGreaterThan(0); + }); + + it("says a failed load failed, and retries it", async () => { + const get = vi + .spyOn(api, "adminGetDownloadQuota") + .mockRejectedValueOnce(new Error("down")) + .mockResolvedValueOnce(QUOTA as never); + open(); + expect((await screen.findByRole("alert")).textContent).toContain("common.loadError"); + fireEvent.click(screen.getByText("common.retry")); + await screen.findByDisplayValue("5"); + expect(get).toHaveBeenCalledTimes(2); + }); + + it("saves the GB field as bytes, and the reset drops the override", async () => { + vi.spyOn(api, "adminGetDownloadQuota").mockResolvedValue(QUOTA as never); + const set = vi.spyOn(api, "adminSetDownloadQuota").mockResolvedValue(QUOTA as never); + const reset = vi.spyOn(api, "adminResetDownloadQuota").mockResolvedValue(QUOTA as never); + const onClose = vi.fn(); + open(onClose); + fireEvent.change(await screen.findByDisplayValue("5"), { target: { value: "1.5" } }); + fireEvent.click(screen.getByText("downloads.admin.save")); + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + expect(set).toHaveBeenCalledWith(4, { + max_bytes: Math.round(1.5 * GiB), + max_jobs: 10, + max_concurrent: 2, + unlimited: false, + }); + + fireEvent.click(screen.getByText("downloads.admin.reset")); + await waitFor(() => expect(reset).toHaveBeenCalledWith(4)); + }); +}); diff --git a/frontend/src/components/downloadAdmin.tsx b/frontend/src/components/downloadAdmin.tsx index 4ed7ecc9..30af39a8 100644 --- a/frontend/src/components/downloadAdmin.tsx +++ b/frontend/src/components/downloadAdmin.tsx @@ -13,6 +13,7 @@ import { jobTitle } from "./downloadTitle"; import { IconButton } from "./ui/IconButton"; import { Input } from "./ui/Input"; import { CheckRow } from "./ui/NativeControls"; +import { LoadingState, StateMessage } from "./ui/QueryState"; import { SURFACE } from "./ui/surfaces"; import UserPicker from "./UserPicker"; @@ -21,7 +22,8 @@ import UserPicker from "./UserPicker"; const GiB = 1024 ** 3; -function QuotaModal({ +/** @internal exported for downloadAdmin.dom.test.tsx */ +export function QuotaModal({ userId, email, onClose, @@ -74,66 +76,80 @@ function QuotaModal({ onSuccess: done, meta: { failTitle: "errors.writeFailed.downloadQuotaReset" }, }); - if (!state) return null; - const upd = (p: Partial) => setForm({ ...state, ...p }); + const upd = (p: Partial>) => { + if (state) setForm({ ...state, ...p }); + }; return ( -
-
- - - -
- upd({ unlimited: checked })} - className="text-sm" - > - {t("downloads.admin.unlimited")} - - - - - -
+ {t("downloads.admin.unlimited")} + + + + + + + )}
); }