feat(files): download from anywhere — a link box, a pasted link, the palette; the running downloads in a tray
- Files toolbar link box (paste button, Enter); a lone http(s) link pasted onto any page opens the one app-wide download dialog, which now names the link; the palette's "Download from a link…" focuses the box on its history entry. - The download tray, bottom right on every page: what waits, runs, is paused or failed (reason + Retry, the queue's right-click / "…" menu), and what finished in this tab as "Done · Open" until closed; folds to a pill (per-account pref); closed, it stays shut — across a reload — until the work changes or something finishes. Back to top steps up over it, the page end keeps clear. - A finish is announced once: "Downloaded: …", whether a phone plays it, Copy link + Open (toasts and notices may carry a second action). - The inbox's ready notice opens the file in its folder: download_done carries job_id + folder_id. - GET /api/downloads is the queue only (in flight, failed, finished in the last 10 minutes), with each row's source key.
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Downloading is an action you start anywhere: a lone http(s) link pasted onto a page (not into a
|
||||
// field, not at a dialog) opens the app's one download dialog; the Files link box hands its link to
|
||||
// the same dialog; the palette's "Download from link…" focuses that box on the entry it navigated to.
|
||||
// The dialog itself is stubbed (it names the link it was opened on).
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
||||
}));
|
||||
vi.mock("./DownloadDialog", () => ({
|
||||
default: ({ source, onClose }: { source: string; onClose: () => void }) => (
|
||||
<div role="dialog" aria-label="download">
|
||||
{source}
|
||||
<button onClick={onClose}>close</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import DownloadIntake from "./DownloadIntake";
|
||||
import { LinkBox } from "./LinkBox";
|
||||
import { clearPendingDownload, requestLinkBox } from "../lib/downloadIntake";
|
||||
import { ensureNavId } from "../lib/history";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
act(clearPendingDownload);
|
||||
});
|
||||
|
||||
function paste(target: EventTarget, text: string) {
|
||||
const event = new Event("paste", { bubbles: true, cancelable: true }) as ClipboardEvent;
|
||||
Object.defineProperty(event, "clipboardData", {
|
||||
value: { getData: (type: string) => (type === "text/plain" ? text : "") },
|
||||
});
|
||||
act(() => {
|
||||
target.dispatchEvent(event);
|
||||
});
|
||||
return event;
|
||||
}
|
||||
|
||||
const dialog = () => screen.queryByRole("dialog", { name: "download" });
|
||||
|
||||
describe("DownloadIntake · a pasted link", () => {
|
||||
it("a link pasted onto the page opens the download dialog on it", async () => {
|
||||
render(<DownloadIntake />);
|
||||
const event = paste(document.body, " https://www.tiktok.com/@a/video/1 ");
|
||||
expect(await screen.findByText("https://www.tiktok.com/@a/video/1")).toBeTruthy();
|
||||
// Taken: the browser does nothing else with it.
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it("text that is not one link, or a paste into a field, is left alone", async () => {
|
||||
render(
|
||||
<>
|
||||
<DownloadIntake />
|
||||
<input aria-label="search" />
|
||||
</>,
|
||||
);
|
||||
paste(document.body, "just some words");
|
||||
paste(document.body, "https://a.example/1 https://a.example/2");
|
||||
const field = screen.getByRole("textbox", { name: "search" });
|
||||
expect(paste(field, "https://a.example/1").defaultPrevented).toBe(false);
|
||||
await act(async () => {});
|
||||
expect(dialog()).toBeNull();
|
||||
});
|
||||
|
||||
it("with a dialog on top, a paste is the dialog's, not a download", async () => {
|
||||
render(
|
||||
<>
|
||||
<DownloadIntake />
|
||||
<div aria-modal="true" />
|
||||
</>,
|
||||
);
|
||||
paste(document.body, "https://a.example/1");
|
||||
await act(async () => {});
|
||||
expect(dialog()).toBeNull();
|
||||
});
|
||||
|
||||
it("closing the dialog closes it", async () => {
|
||||
render(<DownloadIntake />);
|
||||
paste(document.body, "https://a.example/1");
|
||||
fireEvent.click(await screen.findByText("close"));
|
||||
expect(dialog()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LinkBox", () => {
|
||||
it("Enter hands the typed link to the dialog and empties the box", async () => {
|
||||
render(
|
||||
<>
|
||||
<DownloadIntake />
|
||||
<LinkBox />
|
||||
</>,
|
||||
);
|
||||
const field = screen.getByRole("textbox", { name: "files.link.label" });
|
||||
fireEvent.change(field, { target: { value: " https://vimeo.com/123 " } });
|
||||
fireEvent.keyDown(field, { key: "Enter" });
|
||||
expect(await screen.findByText("https://vimeo.com/123")).toBeTruthy();
|
||||
expect((field as HTMLInputElement).value).toBe("");
|
||||
});
|
||||
|
||||
it("an empty box downloads nothing", async () => {
|
||||
render(
|
||||
<>
|
||||
<DownloadIntake />
|
||||
<LinkBox />
|
||||
</>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("files.link.download"));
|
||||
await act(async () => {});
|
||||
expect(dialog()).toBeNull();
|
||||
});
|
||||
|
||||
it("the palette's request focuses the box on its entry, once", () => {
|
||||
ensureNavId();
|
||||
act(requestLinkBox);
|
||||
const { unmount } = render(<LinkBox />);
|
||||
expect(document.activeElement).toBe(screen.getByRole("textbox", { name: "files.link.label" }));
|
||||
unmount();
|
||||
// Spent: the next mount on the same entry is not focused again.
|
||||
render(<LinkBox />);
|
||||
expect(document.activeElement).toBe(document.body);
|
||||
});
|
||||
|
||||
it("a request made for another entry (the navigation never got here) focuses nothing", () => {
|
||||
ensureNavId();
|
||||
act(requestLinkBox);
|
||||
// A later entry — say Back, then elsewhere — mounts the box.
|
||||
window.history.pushState({}, "");
|
||||
ensureNavId();
|
||||
render(<LinkBox />);
|
||||
expect(document.activeElement).toBe(document.body);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user