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:
2026-10-13 00:14:09 +02:00
parent 815083bcc4
commit 8b2260c9a8
41 changed files with 920 additions and 218 deletions
+193 -33
View File
@@ -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();
});
});