feat(files): the Files page — the library in one aligned list, its folders in a rail
Files is the one library: every done file, the ones shared with me, or one folder's, picked in a side rail (sources, own folders, the storage meter) and mirrored to ?folder=. One table with every kind of value in its own column; Copy link, Share and Save in sight, every action on a right click: play, cut, rename in the row, move to an own folder or back home, keep, details, re-download, delete. The Downloads page keeps only the queue until the tray replaces it. The old /api/downloads library routes are gone: list shared, upload, the details PATCH, delete, the file and the usage now live under /api/files, and a listed file says which source it came from. The job's "expired" flag is gone with the state it described.
This commit is contained in:
@@ -1,10 +1,9 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The Download Center: a download row keeps its primary action (play; pause / resume /
|
||||
// retry in the queue) and the save link in sight, and everything else — the editors, sharing,
|
||||
// re-download, the copies, cancel / delete / remove — is its ONE menu, opened by the row's "…" (it
|
||||
// shows on hover or focus) or a right-click on the row. A link inside the row keeps the browser's own
|
||||
// menu. i18n is key-passthrough, so labels read as keys.
|
||||
// The Download Center's queue: a row keeps its primary action (pause / resume / retry) in sight, and
|
||||
// the copies and cancel are its ONE menu, opened by the row's "…" (it shows on hover or focus) or a
|
||||
// right-click on the row. A finished download's row is the Files page's (Files.menu.dom.test.tsx).
|
||||
// i18n is key-passthrough, so labels read as keys.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
@@ -68,7 +67,6 @@ const job = (
|
||||
display_uploader_url: null,
|
||||
extra_links: [],
|
||||
can_download: status === "done",
|
||||
expired: false,
|
||||
job_kind: "download",
|
||||
asset: { media_kind: "video", duration_s: 60 },
|
||||
...extra,
|
||||
@@ -209,160 +207,6 @@ describe("DownloadCenter · the queue row", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("DownloadCenter · the library row", () => {
|
||||
it("shows play, share, save and its '…'; the editors, managing shares and delete are in the menu", async () => {
|
||||
mocks.jobs = [job(2, "done", { source_url: "https://youtu.be/x" })];
|
||||
mount("done");
|
||||
await screen.findByText("Clip 2");
|
||||
expect(rowButtons("Clip 2")).toEqual([
|
||||
"downloads.actions.play",
|
||||
"downloads.actions.share",
|
||||
"downloads.actions.download",
|
||||
"downloads.rowMenu",
|
||||
]);
|
||||
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
|
||||
expect(menuRows()).toEqual([
|
||||
"downloads.actions.play",
|
||||
"downloads.actions.share",
|
||||
"downloads.actions.download",
|
||||
"downloads.actions.edit",
|
||||
"downloads.actions.editDetails",
|
||||
"downloads.actions.redownload",
|
||||
"downloads.actions.shareManage",
|
||||
"common.copy.title",
|
||||
"common.copy.link",
|
||||
"downloads.actions.delete",
|
||||
]);
|
||||
});
|
||||
|
||||
it("a started item continues — its bar shows how far — and its menu can start it over", async () => {
|
||||
mocks.jobs = [job(2, "done", { position_seconds: 30 })];
|
||||
mount("done");
|
||||
await screen.findByText("Clip 2");
|
||||
expect(rowButtons("Clip 2")[0]).toBe("card.continueTitle");
|
||||
// The media cards' bar along the picture's bottom edge: 30 s of the 60 s item.
|
||||
expect(rowOf("Clip 2").querySelector<HTMLElement>(".bg-accent")!.style.width).toBe("50%");
|
||||
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
|
||||
expect(menuRows().slice(0, 2)).toEqual(["card.continue", "card.restartTitle"]);
|
||||
|
||||
// Starting over opens the player at 0:00, not at the kept point.
|
||||
fireEvent.click(
|
||||
screen
|
||||
.getAllByRole("menuitem", { hidden: true })
|
||||
.find((b) => b.textContent === "card.restartTitle")!,
|
||||
);
|
||||
const video = await waitFor(() => {
|
||||
const v = document.querySelector("video");
|
||||
expect(v).not.toBeNull();
|
||||
return v!;
|
||||
});
|
||||
let t = 0;
|
||||
Object.defineProperties(video, {
|
||||
currentTime: { get: () => t, set: (v: number) => void (t = v), configurable: true },
|
||||
duration: { get: () => 60, configurable: true },
|
||||
readyState: { get: () => 1, configurable: true },
|
||||
});
|
||||
video.dispatchEvent(new Event("loadedmetadata"));
|
||||
expect(video.currentTime).toBe(0);
|
||||
});
|
||||
|
||||
it("an item never played offers Play only, with no bar", async () => {
|
||||
mocks.jobs = [job(2, "done", { position_seconds: 0 })];
|
||||
mount("done");
|
||||
await screen.findByText("Clip 2");
|
||||
expect(rowOf("Clip 2").querySelector(".bg-accent")).toBeNull();
|
||||
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
|
||||
expect(menuRows()).not.toContain("card.restartTitle");
|
||||
});
|
||||
|
||||
it("copies the share link from the row's own Share button", async () => {
|
||||
mocks.jobs = [job(2, "done")];
|
||||
vi.spyOn(api, "downloadLinks").mockResolvedValue([] as never);
|
||||
const create = vi
|
||||
.spyOn(api, "createDownloadLink")
|
||||
.mockResolvedValue({ id: 1, url: "/s/tok" } as never);
|
||||
mount("done");
|
||||
await screen.findByText("Clip 2");
|
||||
fireEvent.click(
|
||||
rowOf("Clip 2").querySelector<HTMLElement>('button[aria-label="downloads.actions.share"]')!,
|
||||
);
|
||||
await waitFor(() => expect(create).toHaveBeenCalledWith(2, {}));
|
||||
expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("saves the file through the same link as the row's save button", async () => {
|
||||
mocks.jobs = [job(2, "done")];
|
||||
const followed: string[] = [];
|
||||
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (
|
||||
this: HTMLAnchorElement,
|
||||
) {
|
||||
followed.push(this.href);
|
||||
});
|
||||
mount("done");
|
||||
await screen.findByText("Clip 2");
|
||||
await openMenu(() => fireEvent.click(moreOf("Clip 2")));
|
||||
fireEvent.click(
|
||||
screen
|
||||
.getAllByRole("menuitem", { hidden: true })
|
||||
.find((b) => b.textContent === "downloads.actions.download")!,
|
||||
);
|
||||
const button = rowOf("Clip 2").querySelector<HTMLAnchorElement>(
|
||||
'a[aria-label="downloads.actions.download"]',
|
||||
)!;
|
||||
expect(followed).toEqual([button.href]);
|
||||
});
|
||||
|
||||
it("leaves out what a file or a clip cannot do, and no rule is left behind", async () => {
|
||||
mocks.jobs = [job(3, "done", { job_kind: "upload", asset: { media_kind: "file" } } as never)];
|
||||
mount("done");
|
||||
await screen.findByText("Clip 3");
|
||||
await openMenu(() => fireEvent.click(moreOf("Clip 3")));
|
||||
expect(menuRows()).toEqual([
|
||||
"downloads.actions.share",
|
||||
"downloads.actions.download",
|
||||
"downloads.actions.shareManage",
|
||||
"common.copy.title",
|
||||
"downloads.actions.delete",
|
||||
]);
|
||||
});
|
||||
|
||||
it("gives a link inside the row the browser's own menu", async () => {
|
||||
mocks.jobs = [job(2, "done", { source_url: "https://youtu.be/x" })];
|
||||
mount("done");
|
||||
const link = await screen.findByText("youtu.be/x");
|
||||
const ev = new MouseEvent("contextmenu", { bubbles: true, cancelable: true });
|
||||
act(() => void link.dispatchEvent(ev));
|
||||
expect(ev.defaultPrevented).toBe(false);
|
||||
expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DownloadCenter · the shared row", () => {
|
||||
it("holds play, save, edit, the copies and Remove from my list", async () => {
|
||||
mocks.jobs = [];
|
||||
vi.spyOn(api, "downloadsShared").mockResolvedValue([
|
||||
job(4, "done", { can_download: true }),
|
||||
] as never);
|
||||
const remove = vi.spyOn(api, "removeSharedDownload").mockResolvedValue({} as never);
|
||||
mount("shared");
|
||||
await screen.findByText("Clip 4");
|
||||
await openMenu(() => fireEvent.click(moreOf("Clip 4")));
|
||||
expect(menuRows()).toEqual([
|
||||
"downloads.actions.play",
|
||||
"downloads.actions.download",
|
||||
"downloads.actions.edit",
|
||||
"common.copy.title",
|
||||
"downloads.actions.removeShared",
|
||||
]);
|
||||
fireEvent.click(
|
||||
screen
|
||||
.getAllByRole("menuitem", { hidden: true })
|
||||
.find((b) => b.textContent === "downloads.actions.removeShared")!,
|
||||
);
|
||||
await waitFor(() => expect(remove).toHaveBeenCalledWith(4));
|
||||
});
|
||||
});
|
||||
|
||||
describe("menuGroups", () => {
|
||||
const row = (id: string) => ({ id, label: id, onSelect: () => {} });
|
||||
it("rules between non-empty groups only", () => {
|
||||
|
||||
Reference in New Issue
Block a user