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:
@@ -0,0 +1,397 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The Files page: a row keeps Copy link, Share, Save and its "…" in sight — a slot an action does not
|
||||
// apply to stays empty, so the icons line up down the list — and its every action is its ONE menu,
|
||||
// opened by the "…" or a right-click. Renaming happens in the row; moving goes to an own folder or
|
||||
// back home; a shared file is the owner's, so its menu only plays, saves, cuts or drops it from my
|
||||
// list. 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";
|
||||
import { api, type FileFolder, type LibraryFile } from "../lib/api";
|
||||
import { ALL_FILES, FilesActionsContext, FilesStateContext, type FilesView } from "./filesContext";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
files: [] as unknown[],
|
||||
folders: [] as unknown[],
|
||||
confirm: vi.fn(async (_o?: unknown) => true),
|
||||
keys: [] as unknown[],
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
i18n: { language: "en" },
|
||||
t: (key: string, o?: Record<string, unknown>) =>
|
||||
o && "folder" in o ? `${key}:${String(o.folder)}` : key,
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm }));
|
||||
vi.mock("../lib/useLiveQuery", () => ({
|
||||
useLiveQuery: (key: readonly unknown[]) => {
|
||||
mocks.keys.push(key);
|
||||
return {
|
||||
data:
|
||||
key[0] === "files" ? mocks.files : key[0] === "file-folders" ? mocks.folders : undefined,
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
};
|
||||
},
|
||||
}));
|
||||
vi.mock("./ui/PageShell", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./ui/PageShell")>()),
|
||||
PageToolbar: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
import Files from "./Files";
|
||||
|
||||
const file = (id: number, extra: Partial<LibraryFile> = {}): LibraryFile =>
|
||||
({
|
||||
id,
|
||||
status: "done",
|
||||
progress: 100,
|
||||
created_at: "2026-10-10T10:00:00+00:00",
|
||||
source_kind: "youtube",
|
||||
source_ref: `v${id}`,
|
||||
source_url: null,
|
||||
poster_url: null,
|
||||
display_name: `Clip ${id}`,
|
||||
display_uploader: null,
|
||||
extra_links: [],
|
||||
can_download: true,
|
||||
job_kind: "download",
|
||||
folder_id: 1,
|
||||
kept: false,
|
||||
expires_at: null,
|
||||
shared: false,
|
||||
source_key: "youtube",
|
||||
asset: { media_kind: "video", duration_s: 60, size_bytes: 2048 },
|
||||
...extra,
|
||||
}) as unknown as LibraryFile;
|
||||
|
||||
const folder = (id: number, extra: Partial<FileFolder>): FileFolder => ({
|
||||
id,
|
||||
kind: "source",
|
||||
source_key: null,
|
||||
name: null,
|
||||
retention_days: 30,
|
||||
count: 1,
|
||||
size_bytes: 0,
|
||||
created_at: null,
|
||||
...extra,
|
||||
});
|
||||
const YT = folder(1, { source_key: "youtube" });
|
||||
const OWN = folder(2, { kind: "own", name: "Cimboráknak", retention_days: null });
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
mocks.confirm.mockClear();
|
||||
mocks.keys = [];
|
||||
});
|
||||
|
||||
function mount(view: FilesView = ALL_FILES, setView = vi.fn(), search = "") {
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<FilesActionsContext.Provider value={{ setSearch: vi.fn(), setView }}>
|
||||
<FilesStateContext.Provider value={{ search, view }}>
|
||||
<Files />
|
||||
</FilesStateContext.Provider>
|
||||
</FilesActionsContext.Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return setView;
|
||||
}
|
||||
|
||||
const rowOf = (text: string) => screen.getByText(text).closest<HTMLElement>("[data-nav-index]")!;
|
||||
const moreOf = (text: string) =>
|
||||
rowOf(text).querySelector<HTMLElement>('button[aria-label="files.rowMenu"]')!;
|
||||
// A folder in the Move submenu is a choice of one (menuitemradio), the rest plain rows.
|
||||
const menuEls = () => [
|
||||
...screen.getAllByRole("menuitem", { hidden: true }),
|
||||
...screen.queryAllByRole("menuitemradio", { hidden: true }),
|
||||
];
|
||||
const menuRows = () => menuEls().map((b) => b.textContent ?? "");
|
||||
const menuItem = (label: string) => menuEls().find((b) => b.textContent === label)!;
|
||||
async function openMenu(open: () => void) {
|
||||
act(open);
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0),
|
||||
);
|
||||
}
|
||||
const rowButtons = (text: string) =>
|
||||
[...rowOf(text).querySelectorAll<HTMLElement>("button[aria-label], a[aria-label]")].map((b) =>
|
||||
b.getAttribute("aria-label"),
|
||||
);
|
||||
|
||||
describe("Files · my file's row", () => {
|
||||
it("shows Copy link, Share, Save and the '…'; the menu holds every action", async () => {
|
||||
mocks.files = [file(2, { source_url: "https://youtu.be/x" })];
|
||||
mocks.folders = [YT, OWN];
|
||||
mount();
|
||||
expect(rowButtons("Clip 2")).toEqual([
|
||||
"files.actions.copyLink",
|
||||
"files.actions.share",
|
||||
"downloads.actions.download",
|
||||
"files.rowMenu",
|
||||
]);
|
||||
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
|
||||
expect(menuRows()).toEqual([
|
||||
"downloads.actions.play",
|
||||
"files.actions.copyLink",
|
||||
"files.actions.share",
|
||||
"downloads.actions.download",
|
||||
"downloads.actions.edit",
|
||||
"files.actions.rename",
|
||||
"files.actions.move",
|
||||
"files.actions.keep",
|
||||
"files.actions.details",
|
||||
"downloads.actions.redownload",
|
||||
"common.copy.title",
|
||||
"files.actions.copySourceLink",
|
||||
"downloads.actions.delete",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the slot of an action a plain file lacks, so the icons line up", () => {
|
||||
mocks.files = [file(3, { job_kind: "upload", asset: { media_kind: "file" } } as never)];
|
||||
mocks.folders = [YT];
|
||||
mount();
|
||||
expect(rowButtons("Clip 3")).toEqual([
|
||||
"files.actions.share",
|
||||
"downloads.actions.download",
|
||||
"files.rowMenu",
|
||||
]);
|
||||
// The empty slot holds the Copy link's place, the same width as an icon.
|
||||
const actions = rowOf("Clip 3").querySelector(
|
||||
'button[aria-label="files.actions.share"]',
|
||||
)!.parentElement!;
|
||||
expect(actions.firstElementChild!.getAttribute("aria-hidden")).toBe("true");
|
||||
});
|
||||
|
||||
it("copies the link from the row's own icon", async () => {
|
||||
mocks.files = [file(2)];
|
||||
mocks.folders = [YT];
|
||||
vi.spyOn(api, "downloadLinks").mockResolvedValue([] as never);
|
||||
const create = vi
|
||||
.spyOn(api, "createDownloadLink")
|
||||
.mockResolvedValue({ id: 1, url: "/s/tok" } as never);
|
||||
mount();
|
||||
fireEvent.click(
|
||||
rowOf("Clip 2").querySelector<HTMLElement>('button[aria-label="files.actions.copyLink"]')!,
|
||||
);
|
||||
await waitFor(() => expect(create).toHaveBeenCalledWith(2, {}));
|
||||
});
|
||||
|
||||
it("saves through the file's own address", async () => {
|
||||
mocks.files = [file(2)];
|
||||
mocks.folders = [YT];
|
||||
const followed: string[] = [];
|
||||
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (
|
||||
this: HTMLAnchorElement,
|
||||
) {
|
||||
followed.push(new URL(this.href).pathname);
|
||||
});
|
||||
mount();
|
||||
await openMenu(() => fireEvent.click(moreOf("Clip 2")));
|
||||
fireEvent.click(menuItem("downloads.actions.download"));
|
||||
expect(followed).toEqual(["/api/files/2/file"]);
|
||||
const icon = rowOf("Clip 2").querySelector<HTMLAnchorElement>(
|
||||
'a[aria-label="downloads.actions.download"]',
|
||||
)!;
|
||||
expect(new URL(icon.href).pathname).toBe("/api/files/2/file");
|
||||
});
|
||||
|
||||
it("renames in the row: Enter saves, an emptied name gives the title back", async () => {
|
||||
mocks.files = [file(2)];
|
||||
mocks.folders = [YT];
|
||||
const update = vi.spyOn(api, "updateFile").mockResolvedValue({} as never);
|
||||
mount();
|
||||
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
|
||||
fireEvent.click(menuItem("files.actions.rename"));
|
||||
const field = await screen.findByRole("textbox", { name: "files.actions.rename" });
|
||||
fireEvent.change(field, { target: { value: " Új név " } });
|
||||
fireEvent.keyDown(field, { key: "Enter" });
|
||||
await waitFor(() => expect(update).toHaveBeenCalledWith(2, { name: "Új név" }));
|
||||
expect(screen.queryByRole("textbox", { name: "files.actions.rename" })).toBeNull();
|
||||
|
||||
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
|
||||
fireEvent.click(menuItem("files.actions.rename"));
|
||||
const again = await screen.findByRole("textbox", { name: "files.actions.rename" });
|
||||
fireEvent.change(again, { target: { value: "" } });
|
||||
fireEvent.keyDown(again, { key: "Enter" });
|
||||
await waitFor(() => expect(update).toHaveBeenLastCalledWith(2, { name: null }));
|
||||
});
|
||||
|
||||
it("Escape leaves the name as it was", async () => {
|
||||
mocks.files = [file(2)];
|
||||
mocks.folders = [YT];
|
||||
const update = vi.spyOn(api, "updateFile").mockResolvedValue({} as never);
|
||||
mount();
|
||||
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
|
||||
fireEvent.click(menuItem("files.actions.rename"));
|
||||
const field = await screen.findByRole("textbox", { name: "files.actions.rename" });
|
||||
fireEvent.change(field, { target: { value: "Más" } });
|
||||
fireEvent.keyDown(field, { key: "Escape" });
|
||||
expect(screen.queryByRole("textbox")).toBeNull();
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("moves to an own folder, and from there back to its source folder only", async () => {
|
||||
mocks.files = [file(2, { folder_id: 2 })];
|
||||
// Another source folder first, so "its own" is chosen by its key, not by its place in the list.
|
||||
mocks.folders = [folder(3, { source_key: "site:tiktok.com" }), YT, OWN];
|
||||
const update = vi.spyOn(api, "updateFile").mockResolvedValue({} as never);
|
||||
mount();
|
||||
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
|
||||
fireEvent.keyDown(menuItem("files.actions.move"), { key: "ArrowRight" });
|
||||
await waitFor(() => expect(menuRows()).toContain("Cimboráknak"));
|
||||
// The folder it is in, ticked; home is its own source folder, never another site's.
|
||||
expect(menuRows()).toContain("files.actions.moveHome:files.source.youtube");
|
||||
expect(menuRows().some((r) => r.includes("tiktok"))).toBe(false);
|
||||
expect(menuItem("Cimboráknak").getAttribute("aria-checked")).toBe("true");
|
||||
fireEvent.click(menuItem("files.actions.moveHome:files.source.youtube"));
|
||||
await waitFor(() => expect(update).toHaveBeenCalledWith(2, { folder_id: 1 }));
|
||||
});
|
||||
|
||||
it("offers no move home while it is home, and says so when there is nowhere to go", async () => {
|
||||
mocks.files = [file(2, { folder_id: 1 })];
|
||||
mocks.folders = [YT];
|
||||
mount();
|
||||
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
|
||||
fireEvent.keyDown(menuItem("files.actions.move"), { key: "ArrowRight" });
|
||||
await waitFor(() => expect(menuRows()).toContain("files.actions.noOwnFolders"));
|
||||
expect(menuItem("files.actions.noOwnFolders").hasAttribute("disabled")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a file, and a kept one is handed back to its folder", async () => {
|
||||
mocks.files = [file(2), file(3, { kept: true })];
|
||||
mocks.folders = [YT];
|
||||
const update = vi.spyOn(api, "updateFile").mockResolvedValue({} as never);
|
||||
mount();
|
||||
expect(rowOf("Clip 3").textContent).toContain("files.retention.kept");
|
||||
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
|
||||
fireEvent.click(menuItem("files.actions.keep"));
|
||||
await waitFor(() => expect(update).toHaveBeenCalledWith(2, { kept: true }));
|
||||
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
|
||||
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 3")));
|
||||
fireEvent.click(menuItem("files.actions.unkeep"));
|
||||
await waitFor(() => expect(update).toHaveBeenLastCalledWith(3, { kept: false }));
|
||||
});
|
||||
|
||||
it("deletes only through the confirm", async () => {
|
||||
mocks.files = [file(2)];
|
||||
mocks.folders = [YT];
|
||||
const del = vi.spyOn(api, "deleteFile").mockResolvedValue({} as never);
|
||||
mocks.confirm.mockResolvedValueOnce(false);
|
||||
mount();
|
||||
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
|
||||
fireEvent.click(menuItem("downloads.actions.delete"));
|
||||
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
|
||||
expect(del).not.toHaveBeenCalled();
|
||||
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
|
||||
fireEvent.click(menuItem("downloads.actions.delete"));
|
||||
await waitFor(() => expect(del).toHaveBeenCalledWith(2));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Files · the confirms", () => {
|
||||
it("label the confirm button with the action, not the dialog's question", async () => {
|
||||
mocks.files = [file(2)];
|
||||
mocks.folders = [YT];
|
||||
mocks.confirm.mockResolvedValue(false);
|
||||
mount();
|
||||
await openMenu(() => fireEvent.click(moreOf("Clip 2")));
|
||||
fireEvent.click(menuItem("downloads.actions.redownload"));
|
||||
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
|
||||
await openMenu(() => fireEvent.click(moreOf("Clip 2")));
|
||||
fireEvent.click(menuItem("downloads.actions.delete"));
|
||||
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(2));
|
||||
expect(
|
||||
mocks.confirm.mock.calls.map(([o]) => {
|
||||
const opts = o as { title: string; confirmLabel?: string };
|
||||
return [opts.title, opts.confirmLabel];
|
||||
}),
|
||||
).toEqual([
|
||||
["downloads.confirm.redownloadTitle", "downloads.confirm.redownloadAction"],
|
||||
["downloads.confirm.deleteTitle", "downloads.actions.delete"],
|
||||
]);
|
||||
mocks.confirm.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["upload", "downloads.confirm.deleteUploadTitle", "downloads.confirm.deleteUploadBody"],
|
||||
["edit", "downloads.confirm.deleteEditTitle", "downloads.confirm.deleteEditBody"],
|
||||
["download", "downloads.confirm.deleteTitle", "downloads.confirm.deleteBody"],
|
||||
] as const)("deleting a %s says what happens to its file", async (kind, title, body) => {
|
||||
// Only a download's file may stay cached for other users; an upload's or a clip's is theirs alone.
|
||||
mocks.files = [file(5, { job_kind: kind })];
|
||||
mocks.folders = [YT];
|
||||
mocks.confirm.mockResolvedValueOnce(false);
|
||||
mount();
|
||||
await openMenu(() => fireEvent.click(moreOf("Clip 5")));
|
||||
fireEvent.click(menuItem("downloads.actions.delete"));
|
||||
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
|
||||
const opts = mocks.confirm.mock.calls[0]![0] as { title: string; message?: string };
|
||||
expect([opts.title, opts.message]).toEqual([title, body]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Files · a shared file's row", () => {
|
||||
it("plays, saves, cuts, copies and drops it from my list — nothing of the owner's", async () => {
|
||||
mocks.files = [file(4, { shared: true, shared_at: "2026-10-11T08:00:00+00:00" })];
|
||||
mocks.folders = [YT];
|
||||
const remove = vi.spyOn(api, "removeSharedFile").mockResolvedValue({} as never);
|
||||
mount({ kind: "shared" });
|
||||
expect(rowButtons("Clip 4")).toEqual(["downloads.actions.download", "files.rowMenu"]);
|
||||
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(menuItem("downloads.actions.removeShared"));
|
||||
await waitFor(() => expect(remove).toHaveBeenCalledWith(4));
|
||||
// The shared list is its own read, and nothing can be uploaded into it.
|
||||
expect(mocks.keys).toContainEqual(["files", { shared: true }]);
|
||||
expect(screen.queryByText("files.actions.upload")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Files · the view", () => {
|
||||
it("reads one folder's files and names the folder", () => {
|
||||
mocks.files = [file(2, { folder_id: 2 })];
|
||||
mocks.folders = [YT, OWN];
|
||||
mount({ kind: "folder", id: 2 });
|
||||
expect(mocks.keys).toContainEqual(["files", { folderId: 2 }]);
|
||||
expect(screen.getByRole("heading", { name: "Cimboráknak" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens the whole library when the folder it was given is gone", async () => {
|
||||
mocks.files = [];
|
||||
mocks.folders = [YT];
|
||||
const setView = mount({ kind: "folder", id: 99 });
|
||||
expect(mocks.keys).toContainEqual(["files", {}]);
|
||||
expect(mocks.keys).not.toContainEqual(["files", { folderId: 99 }]);
|
||||
await waitFor(() => expect(setView).toHaveBeenCalledWith(ALL_FILES));
|
||||
});
|
||||
|
||||
it("searches the rows it holds by name and channel", () => {
|
||||
mocks.files = [
|
||||
file(2, { display_name: "Szméagol szompolyog" }),
|
||||
file(3, { display_name: "Más", display_uploader: "Szméagol rajongó" }),
|
||||
file(4, { display_name: "Harmadik" }),
|
||||
];
|
||||
mocks.folders = [YT];
|
||||
mount(ALL_FILES, vi.fn(), "szméagol");
|
||||
expect(screen.queryByText("Szméagol szompolyog")).not.toBeNull();
|
||||
expect(screen.queryByText("Más")).not.toBeNull();
|
||||
expect(screen.queryByText("Harmadik")).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user