+ {t("downloads.share.linkCopied")} +
+ )}{t("downloads.share.noLinks")}
diff --git a/frontend/src/modules/files/UploadDialog.dom.test.tsx b/frontend/src/modules/files/UploadDialog.dom.test.tsx index 671d0f2c..bb3f3b41 100644 --- a/frontend/src/modules/files/UploadDialog.dom.test.tsx +++ b/frontend/src/modules/files/UploadDialog.dom.test.tsx @@ -19,6 +19,8 @@ const h = vi.hoisted(() => ({ uploadStarted: vi.fn(), uploadEnded: vi.fn(), openFile: vi.fn(), + fileFolders: vi.fn(), + createFolder: vi.fn(), })); vi.mock("react-i18next", async (importOriginal) => { @@ -29,7 +31,12 @@ vi.mock("react-i18next", async (importOriginal) => { }; }); vi.mock("../../lib/api", () => ({ - api: { uploadFile: h.uploadFile, filesUsage: h.filesUsage }, + api: { + uploadFile: h.uploadFile, + filesUsage: h.filesUsage, + fileFolders: h.fileFolders, + createFolder: h.createFolder, + }, })); vi.mock("../../lib/downloads", () => ({ invalidateDownloads: vi.fn() })); // The rest of the module stays real: the dialog is a ui/Modal, which hosts its own toasts. @@ -47,12 +54,29 @@ import UploadDialog from "./UploadDialog"; import { FilesActionsContext } from "./filesContext"; import { msg, type NotifText } from "../../lib/notifications"; +const folder = (id: number, kind: string, source_key: string | null, name: string | null) => ({ + id, + kind, + source_key, + name, + retention_days: null, + count: 0, + size_bytes: 0, + created_at: null, + has_link: false, +}); +const FOLDERS = [ + folder(1, "source", "upload", null), + folder(5, "own", null, "peat"), + folder(9, "source", "site:reddit.com", null), +]; + const wrap = ( onClose = vi.fn(), folderId: number | null = null, initialFile: File | null = null, ) => { - const qc = new QueryClient(); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const files = { setSearch: vi.fn(), setView: vi.fn(), @@ -78,9 +102,109 @@ beforeEach(() => { h.notify.mockClear(); h.filesUsage.mockClear(); h.filesUsage.mockResolvedValue({ upload_max_bytes: 1_000_000 }); + h.createFolder.mockReset(); + h.fileFolders.mockReset(); + h.fileFolders.mockResolvedValue(FOLDERS); }); afterEach(cleanup); +describe("UploadDialog · where the file goes", () => { + const pick = () => + fireEvent.change(fileInput(), { target: { files: [new File(["x"], "a.pdf")] } }); + const target = () => screen.findByTestId("upload-target"); + const sentTo = async () => { + fireEvent.click(screen.getByText("downloads.upload.submit")); + await waitFor(() => expect(h.uploadFile).toHaveBeenCalledTimes(1)); + return (h.uploadFile.mock.calls[0]![1] as { folderId: number | null }).folderId; + }; + + it("says the folder it was opened on", async () => { + wrap(vi.fn(), 5); + expect((await target()).textContent).toContain("peat"); + }); + + it("says Uploads when it was opened on no folder, and sends there", async () => { + wrap(); + expect((await target()).textContent).toContain("files.source.upload"); + pick(); + expect(await sentTo()).toBeNull(); + }); + + it("says Uploads before the user has a folder of that name yet", async () => { + h.fileFolders.mockResolvedValue([FOLDERS[1]]); + wrap(); + expect((await target()).textContent).toContain("files.source.upload"); + }); + + it("offers only the folders that take an upload", async () => { + wrap(); + fireEvent.click(await target()); + expect(await screen.findByTestId("upload-target-5")).toBeTruthy(); + expect(screen.getByTestId("upload-target-1")).toBeTruthy(); + expect(screen.queryByTestId("upload-target-9")).toBeNull(); // a site's folder claims an origin + }); + + it("sends to the folder picked", async () => { + wrap(); + fireEvent.click(await target()); + fireEvent.click(await screen.findByTestId("upload-target-5")); + pick(); + expect(await sentTo()).toBe(5); + }); + + it("makes a new folder from the same list and sends there", async () => { + h.createFolder.mockResolvedValue({ ...FOLDERS[1], id: 7, name: "New one" }); + wrap(); + fireEvent.click(await target()); + fireEvent.click(await screen.findByTestId("upload-target-new")); + const field = await screen.findByLabelText("files.folder.newName"); + fireEvent.change(field, { target: { value: "New one" } }); + fireEvent.keyDown(field, { key: "Enter" }); + await waitFor(() => expect(h.createFolder).toHaveBeenCalledWith("New one")); + await waitFor(async () => expect((await target()).textContent).toContain("New one")); + pick(); + expect(await sentTo()).toBe(7); + }); + + it("will not upload while a new folder is on its way, to the wrong place", async () => { + let made: (f: unknown) => void = () => {}; + h.createFolder.mockReturnValue(new Promise((res) => (made = res))); + wrap(); + pick(); + fireEvent.click(await target()); + fireEvent.click(await screen.findByTestId("upload-target-new")); + const field = await screen.findByLabelText("files.folder.newName"); + const submit = screen.getByText("downloads.upload.submit").closest("button")!; + expect(submit.disabled || submit.getAttribute("aria-disabled") === "true").toBe(true); + fireEvent.change(field, { target: { value: "New one" } }); + fireEvent.keyDown(field, { key: "Enter" }); + await waitFor(() => expect(h.createFolder).toHaveBeenCalled()); + fireEvent.click(submit); + expect(h.uploadFile).not.toHaveBeenCalled(); + made({ ...FOLDERS[1], id: 7, name: "New one" }); + await waitFor(() => expect(submit.disabled).toBe(false)); + expect(await sentTo()).toBe(7); + }); + + it("an empty name makes no folder and leaves the target", async () => { + wrap(vi.fn(), 5); + fireEvent.click(await target()); + fireEvent.click(await screen.findByTestId("upload-target-new")); + fireEvent.keyDown(await screen.findByLabelText("files.folder.newName"), { key: "Enter" }); + expect((await target()).textContent).toContain("peat"); + expect(h.createFolder).not.toHaveBeenCalled(); + }); + + it("says when the folders could not be read, and offers Retry", async () => { + h.fileFolders.mockRejectedValue(new Error("down")); + wrap(); + expect(await screen.findByText("downloads.upload.targetFailed")).toBeTruthy(); + h.fileFolders.mockResolvedValue(FOLDERS); + fireEvent.click(screen.getByRole("button", { name: /retry/i })); + expect((await target()).textContent).toContain("files.source.upload"); + }); +}); + describe("UploadDialog", () => { it("shows the picked file's name and uploads it with the optional name", async () => { const onClose = vi.fn(); diff --git a/frontend/src/modules/files/UploadDialog.tsx b/frontend/src/modules/files/UploadDialog.tsx index 1f545af7..764e713e 100644 --- a/frontend/src/modules/files/UploadDialog.tsx +++ b/frontend/src/modules/files/UploadDialog.tsx @@ -1,10 +1,10 @@ import { useEffect, useId, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { UploadCloud, File as FileIcon } from "lucide-react"; import Modal, { ModalFooter } from "../../components/ui/Modal"; import { Button } from "../../components/ui/Button"; -import { api } from "../../lib/api"; +import { api, type FileFolder } from "../../lib/api"; import { qk } from "../../lib/queryKeys"; import { isAborted } from "../../lib/api/core"; import { notifyActionError } from "../../lib/actionErrors"; @@ -17,6 +17,10 @@ import { formatBytes } from "../../lib/format"; import { DISPLAY_NAME_MAX } from "../../lib/api/downloads"; import { DropZone } from "../../components/ui/DropZone"; import { Input } from "../../components/ui/Input"; +import FilterSelect from "../../components/ui/FilterSelect"; +import { RenameField } from "../../components/ui/RenameField"; +import { StateMessage } from "../../components/ui/QueryState"; +import { folderLabel, takesUploads } from "../../features/folders/fileFolders"; // Local-file ingest. A general upload — ANY file type, no format list: media // (video/audio) joins the library with the full function set, a generic file is download-only. The @@ -30,6 +34,12 @@ const uploadedKey = (status: string) => status === "done" ? "downloads.upload.added" : "downloads.upload.addedConverting"; // The job the upload's own notice announces (a done one), for the queue not to say it again. const announces = (job: { id: number; status: string }) => (job.status === "done" ? job.id : null); +// What stands in the target list for Uploads before the user has that folder (the first upload makes +// it), and for the row that makes a folder. +const UPLOADS = "uploads"; +const NEW_FOLDER = "new"; +const isUploads = (f: FileFolder) => f.kind === "source" && f.source_key === "upload"; + export default function UploadDialog({ folderId = null, initialFile = null, @@ -65,6 +75,48 @@ export default function UploadDialog({ // same reason, and `formAlerts.dom.test.tsx` is the test that exists because of it. const [pickSeq, setPickSeq] = useState(0); const { openFile } = useFilesActions(); + + // Where it goes: the folder the dialog was opened on, until the user says otherwise. Null = Uploads. + const [target, setTarget] = useState(folderId); + const foldersQ = useQuery({ queryKey: qk.fileFolders(), queryFn: api.fileFolders }); + const [naming, setNaming] = useState(false); + const targetBox = useRef+ {t("downloads.upload.targetLabel")} +
+ {foldersQ.isError ? ( + // The file still goes where the dialog was opened on: only the list could not be read. +