fix(files): review round 1, the page — honest states, the keyboard, the tray's words
- A library past the list's cap is searched and sorted on the server; the count says "the newest". - Each folder and each search opens on its first page (DataTable pageScope, useCardPager scope). - A file dropped where the page takes none never reaches the browser (which would unload the app). - The viewer's arrows wait while a layer opened from it is on top, and scroll a wide text line; a text shows its first 5000 lines and says where the rest is. - Confirms name the file (no more "your downloads"); the asking menu rows end in "…". - The tray names a running step beside its bar, shows a pause mark for paused work, polls slowly while only paused work waits, and forgets a finished file that was deleted. - An unchanged rename sends nothing; a paste opens the download dialog only with nothing on top; the link box never replaces what was typed. - Honest states: folders that failed to load, a file no longer there, the storage figure; the rail says "nothing downloaded" only when there is nothing. - Focus after deleting the open folder and when the format form opens; the format name's label. - ui/Meter (one danger line for both quota bars) and ui/RenameField in the platform layer. - Copy fixes, stale comments and the speculative hub key; a contract test for the list's parameters; e2e: the Files page and the paste intake.
This commit is contained in:
@@ -26,6 +26,14 @@ const mocks = vi.hoisted(() => ({
|
||||
confirm: vi.fn(async (_o?: unknown) => true),
|
||||
keys: [] as unknown[],
|
||||
fetching: false,
|
||||
titleArgs: [] as unknown[],
|
||||
searched: [] as unknown[],
|
||||
foldersFailed: false,
|
||||
notify: vi.fn(),
|
||||
}));
|
||||
vi.mock("../lib/notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../lib/notifications")>()),
|
||||
notify: mocks.notify,
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
@@ -34,19 +42,39 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
i18n: { language: "en" },
|
||||
t: (key: string, o?: Record<string, unknown>) =>
|
||||
o && "folder" in o ? `${key}:${String(o.folder)}` : key,
|
||||
t: (key: string, o?: Record<string, unknown>) => {
|
||||
if (o) mocks.titleArgs.push([key, o]);
|
||||
return o && "folder" in o ? `${key}:${String(o.folder)}` : key;
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock("./LibraryPlayer", () => ({
|
||||
default: ({ job, fromStart }: { job: { id: number }; fromStart?: boolean }) => (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label="player"
|
||||
>{`playing ${job.id}${fromStart ? " from the start" : ""}`}</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm }));
|
||||
vi.mock("../lib/useLiveQuery", () => ({
|
||||
useLiveQuery: (key: readonly unknown[]) => {
|
||||
useLiveQuery: (key: readonly unknown[], _fn: unknown, opts?: { enabled?: boolean }) => {
|
||||
if (opts?.enabled === false) return { data: undefined, isError: false, isLoading: false };
|
||||
mocks.keys.push(key);
|
||||
const asked = key[1] as { q?: string; sort?: string } | undefined;
|
||||
return {
|
||||
data:
|
||||
key[0] === "files" ? mocks.files : key[0] === "file-folders" ? mocks.folders : undefined,
|
||||
isError: false,
|
||||
key[0] === "files"
|
||||
? asked?.q || asked?.sort
|
||||
? mocks.searched
|
||||
: mocks.files
|
||||
: key[0] === "file-folders"
|
||||
? mocks.foldersFailed
|
||||
? undefined
|
||||
: mocks.folders
|
||||
: undefined,
|
||||
isError: key[0] === "file-folders" && mocks.foldersFailed,
|
||||
isLoading: false,
|
||||
isFetching: mocks.fetching,
|
||||
refetch: vi.fn(),
|
||||
@@ -59,6 +87,7 @@ vi.mock("./ui/PageShell", async (importOriginal) => ({
|
||||
}));
|
||||
|
||||
import Files from "./Files";
|
||||
import { FILES_LIST_CAP } from "../lib/api";
|
||||
|
||||
const file = (id: number, extra: Partial<LibraryFile> = {}): LibraryFile =>
|
||||
({
|
||||
@@ -172,10 +201,10 @@ describe("Files · my file's row", () => {
|
||||
"files.actions.move",
|
||||
"files.actions.keep",
|
||||
"files.actions.details",
|
||||
"downloads.actions.redownload",
|
||||
"files.actions.redownload",
|
||||
"common.copy.title",
|
||||
"files.actions.copySourceLink",
|
||||
"downloads.actions.delete",
|
||||
"files.actions.delete",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -265,6 +294,23 @@ describe("Files · my file's row", () => {
|
||||
await waitFor(() => expect(update).toHaveBeenLastCalledWith(2, { name: null }));
|
||||
});
|
||||
|
||||
it("a rename left as it was sends nothing — a name never set stays unset", async () => {
|
||||
// The field starts on the title the file shows; saving that would pin it as a name of its own.
|
||||
mocks.files = [file(2, { display_name: null, asset: { title: "Its own title" } } as never)];
|
||||
mocks.folders = [YT];
|
||||
const update = vi.spyOn(api, "updateFile").mockResolvedValue({} as never);
|
||||
mount();
|
||||
await openMenu(() => fireEvent.contextMenu(rowOf("Its own title")));
|
||||
fireEvent.click(menuItem("files.actions.rename"));
|
||||
const field = await screen.findByRole("textbox", { name: "files.actions.rename" });
|
||||
expect((field as HTMLInputElement).value).toBe("Its own title");
|
||||
fireEvent.keyDown(field, { key: "Enter" });
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("textbox", { name: "files.actions.rename" })).toBeNull(),
|
||||
);
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Escape leaves the name as it was", async () => {
|
||||
mocks.files = [file(2)];
|
||||
mocks.folders = [YT];
|
||||
@@ -329,15 +375,39 @@ describe("Files · my file's row", () => {
|
||||
mocks.confirm.mockResolvedValueOnce(false);
|
||||
mount();
|
||||
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
|
||||
fireEvent.click(menuItem("downloads.actions.delete"));
|
||||
fireEvent.click(menuItem("files.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"));
|
||||
fireEvent.click(menuItem("files.actions.delete"));
|
||||
await waitFor(() => expect(del).toHaveBeenCalledWith(2));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Files · a library past the list's cap", () => {
|
||||
it("a search under the cap runs on the rows; one past it asks the server, so an old file is found", () => {
|
||||
mocks.folders = [YT];
|
||||
mocks.files = [file(1, { display_name: "Recept" }), file(2)];
|
||||
mocks.keys = [];
|
||||
mount(ALL_FILES, vi.fn(), "recept");
|
||||
expect(rowOf("Recept")).toBeTruthy();
|
||||
expect(
|
||||
mocks.keys.some(
|
||||
(k) => (k as unknown[])[0] === "files" && "q" in ((k as unknown[])[1] as object),
|
||||
),
|
||||
).toBe(false);
|
||||
cleanup();
|
||||
|
||||
// The newest FILES_LIST_CAP files came back: the one searched for may be older than all of them.
|
||||
mocks.files = Array.from({ length: FILES_LIST_CAP }, (_, i) => file(i + 1));
|
||||
mocks.searched = [file(9999, { display_name: "Régi recept" })];
|
||||
mocks.keys = [];
|
||||
mount(ALL_FILES, vi.fn(), "recept");
|
||||
expect(rowOf("Régi recept")).toBeTruthy();
|
||||
expect(mocks.keys).toContainEqual(["files", { q: "recept" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Files · the confirms", () => {
|
||||
it("label the confirm button with the action, not the dialog's question", async () => {
|
||||
mocks.files = [file(2)];
|
||||
@@ -345,10 +415,10 @@ describe("Files · the confirms", () => {
|
||||
mocks.confirm.mockResolvedValue(false);
|
||||
mount();
|
||||
await openMenu(() => fireEvent.click(moreOf("Clip 2")));
|
||||
fireEvent.click(menuItem("downloads.actions.redownload"));
|
||||
fireEvent.click(menuItem("files.actions.redownload"));
|
||||
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
|
||||
await openMenu(() => fireEvent.click(moreOf("Clip 2")));
|
||||
fireEvent.click(menuItem("downloads.actions.delete"));
|
||||
fireEvent.click(menuItem("files.actions.delete"));
|
||||
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(2));
|
||||
expect(
|
||||
mocks.confirm.mock.calls.map(([o]) => {
|
||||
@@ -363,20 +433,21 @@ describe("Files · the confirms", () => {
|
||||
});
|
||||
|
||||
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.
|
||||
["upload", "downloads.confirm.deleteUploadBody"],
|
||||
["edit", "downloads.confirm.deleteEditBody"],
|
||||
["download", "downloads.confirm.deleteBody"],
|
||||
] as const)("deleting a %s names it and says what happens to its file", async (kind, body) => {
|
||||
// Only a download's file may stay 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"));
|
||||
fireEvent.click(menuItem("files.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]);
|
||||
expect([opts.title, opts.message]).toEqual(["downloads.confirm.deleteTitle", body]);
|
||||
expect(mocks.titleArgs).toContainEqual(["downloads.confirm.deleteTitle", { name: "Clip 5" }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -393,9 +464,9 @@ describe("Files · a shared file's row", () => {
|
||||
"downloads.actions.download",
|
||||
"downloads.actions.edit",
|
||||
"common.copy.title",
|
||||
"downloads.actions.removeShared",
|
||||
"files.actions.removeShared",
|
||||
]);
|
||||
fireEvent.click(menuItem("downloads.actions.removeShared"));
|
||||
fireEvent.click(menuItem("files.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 }]);
|
||||
@@ -547,7 +618,7 @@ describe("Files · the grid", () => {
|
||||
expect(rowOf("Clip 2").textContent).toContain("files.retention.kept");
|
||||
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
|
||||
expect(menuRows()).toContain("files.actions.move");
|
||||
expect(menuRows()).toContain("downloads.actions.delete");
|
||||
expect(menuRows()).toContain("files.actions.delete");
|
||||
});
|
||||
|
||||
it("is remembered per folder", async () => {
|
||||
@@ -765,17 +836,17 @@ describe("Files · arriving at one file", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Files · dropping a file on the page", () => {
|
||||
const drag = (type: string, files: File[] = []) => {
|
||||
const e = new Event(type, { bubbles: true, cancelable: true });
|
||||
const list = Object.assign([...files], { item: (i: number) => files[i] ?? null });
|
||||
Object.defineProperty(e, "dataTransfer", { value: { types: ["Files"], files: list } });
|
||||
act(() => {
|
||||
window.dispatchEvent(e);
|
||||
});
|
||||
return e;
|
||||
};
|
||||
const drag = (type: string, files: File[] = []) => {
|
||||
const e = new Event(type, { bubbles: true, cancelable: true });
|
||||
const list = Object.assign([...files], { item: (i: number) => files[i] ?? null });
|
||||
Object.defineProperty(e, "dataTransfer", { value: { types: ["Files"], files: list } });
|
||||
act(() => {
|
||||
window.dispatchEvent(e);
|
||||
});
|
||||
return e;
|
||||
};
|
||||
|
||||
describe("Files · dropping a file on the page", () => {
|
||||
it("names the folder it lands in, and opens Upload with the file", async () => {
|
||||
mocks.files = [];
|
||||
mocks.folders = [YT, OWN];
|
||||
@@ -789,11 +860,100 @@ describe("Files · dropping a file on the page", () => {
|
||||
expect(await screen.findByText("recept.pdf")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("is no target where nothing can be uploaded", () => {
|
||||
it("is no target where nothing can be uploaded — but the browser never gets the file", () => {
|
||||
mocks.files = [];
|
||||
mocks.folders = [YT];
|
||||
mount({ kind: "shared" });
|
||||
expect(drag("dragenter").defaultPrevented).toBe(false);
|
||||
drag("dragenter");
|
||||
expect(screen.queryByText(/files\.drop\.here/)).toBeNull();
|
||||
// Left to the browser, a dropped file opens in the tab and the app is gone.
|
||||
const drop = drag("drop", [new File(["x"], "recept.pdf")]);
|
||||
expect(drop.defaultPrevented).toBe(true);
|
||||
expect(screen.queryByText("recept.pdf")).toBeNull();
|
||||
});
|
||||
|
||||
it("a second file dropped beside the open Upload dialog does not unload the app", async () => {
|
||||
mocks.files = [];
|
||||
mocks.folders = [YT, OWN];
|
||||
vi.spyOn(api, "filesUsage").mockResolvedValue({ upload_max_bytes: 1_000_000 } as never);
|
||||
mount({ kind: "folder", id: 2 });
|
||||
drag("drop", [new File(["x"], "recept.pdf")]);
|
||||
expect(await screen.findByText("recept.pdf")).toBeTruthy();
|
||||
expect(drag("dragover").defaultPrevented).toBe(true);
|
||||
expect(drag("drop", [new File(["y"], "masik.pdf")]).defaultPrevented).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Files · when what it needs is missing", () => {
|
||||
afterEach(() => {
|
||||
mocks.foldersFailed = false;
|
||||
mocks.fetching = false;
|
||||
mocks.notify.mockClear();
|
||||
});
|
||||
|
||||
it("folders that failed to load are said to be missing, never shown as none — and take no drop", async () => {
|
||||
mocks.files = [file(2)];
|
||||
mocks.foldersFailed = true;
|
||||
mount({ kind: "folder", id: 2 });
|
||||
expect(screen.getByText("files.folderUnknown")).toBeTruthy();
|
||||
// Dropped now, the file would land in Uploads instead of the folder on screen.
|
||||
drag("dragenter");
|
||||
expect(screen.queryByText(/files\.drop\.here/)).toBeNull();
|
||||
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
|
||||
fireEvent.click(menuItem("files.actions.move"));
|
||||
expect(await screen.findByText("files.actions.foldersUnknown")).toBeTruthy();
|
||||
expect(screen.queryByText("files.actions.noOwnFolders")).toBeNull();
|
||||
});
|
||||
|
||||
it("a folder the cached list does not have yet is waited for, not taken for gone", () => {
|
||||
mocks.files = [file(2)];
|
||||
mocks.folders = [YT];
|
||||
mocks.fetching = true;
|
||||
const setView = mount({ kind: "folder", id: 7 });
|
||||
expect(setView).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opening a file that is no longer there says so", async () => {
|
||||
mocks.files = [file(2)];
|
||||
mocks.folders = [YT];
|
||||
mount(ALL_FILES, vi.fn(), "", { focus: { id: 9, token: 1 } });
|
||||
await waitFor(() =>
|
||||
expect(mocks.notify).toHaveBeenCalledWith({ level: "info", message: "files.arrival.gone" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Files · the open folder deleted", () => {
|
||||
it("hands the keyboard to the page's heading, not to <body>", async () => {
|
||||
// The deleted folder's "…" held the focus and is gone with it.
|
||||
mocks.files = [];
|
||||
mocks.folders = [YT];
|
||||
(document.activeElement as HTMLElement | null)?.blur();
|
||||
mount({ kind: "folder", id: 2 });
|
||||
await waitFor(() =>
|
||||
expect(document.activeElement).toBe(screen.getByRole("heading", { name: "files.rail.all" })),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Files · a file watched part way", () => {
|
||||
it("leads with Continue and offers Start over, which plays it from the start", async () => {
|
||||
mocks.files = [file(2, { position_seconds: 120 })];
|
||||
mocks.folders = [YT];
|
||||
mount();
|
||||
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
|
||||
expect(menuRows().slice(0, 2)).toEqual(["card.continue", "card.restartTitle"]);
|
||||
fireEvent.click(menuItem("card.restartTitle"));
|
||||
expect(await screen.findByText("playing 2 from the start")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("one never played has no Start over, and plays where it is", async () => {
|
||||
mocks.files = [file(3)];
|
||||
mocks.folders = [YT];
|
||||
mount();
|
||||
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 3")));
|
||||
expect(menuRows()).not.toContain("card.restartTitle");
|
||||
fireEvent.click(menuItem("downloads.actions.play"));
|
||||
expect(await screen.findByText("playing 3")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user