fix(files): review round 2, the page — the viewer's Escape, the dialog's drop, the capped list's honesty

- Escape closes the file viewer again: its layer sits above its dialog's, so it takes the Escape.
- The page's drop guard leaves a drag the open Upload dialog's zone accepted alone (it ran last and
  refused that drop).
- A capped list's server sort or search keeps the rows on screen until its answer is in (no skeleton
  under the pressed header), says "The first N items" when that answer is cut too, and an arrival is
  decided on the rows shown: one past the end of a cut list is said to be so, not gone.
- The search an arrival clears is taken at once.
- Tests: confirm names asserted per confirm, a fitting text pages under the arrows, a deleted file
  leaves the tray's finished list.
This commit is contained in:
2026-10-13 02:32:50 +02:00
parent 54a6bbf07f
commit 5bb101e493
6 changed files with 218 additions and 24 deletions
+149 -3
View File
@@ -8,7 +8,15 @@
// list. i18n is key-passthrough, so labels read as keys.
import { useState } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import {
act,
cleanup,
createEvent,
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 { formatDateTime } from "../lib/format";
@@ -28,6 +36,8 @@ const mocks = vi.hoisted(() => ({
fetching: false,
titleArgs: [] as unknown[],
searched: [] as unknown[],
// The server's search/sort answer is still on its way (no data yet).
wideLoading: false,
foldersFailed: false,
notify: vi.fn(),
}));
@@ -63,6 +73,14 @@ vi.mock("../lib/useLiveQuery", () => ({
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;
if (key[0] === "files" && (asked?.q || asked?.sort) && mocks.wideLoading)
return {
data: undefined,
isError: false,
isLoading: true,
isFetching: true,
refetch: vi.fn(),
};
return {
data:
key[0] === "files"
@@ -87,7 +105,8 @@ vi.mock("./ui/PageShell", async (importOriginal) => ({
}));
import Files from "./Files";
import { FILES_LIST_CAP } from "../lib/api";
import { FILES_LIST_CAP, type QueueJob } from "../lib/api";
import { noteQueue, resetQueueWatch, useFinished } from "../lib/downloadQueue";
const file = (id: number, extra: Partial<LibraryFile> = {}): LibraryFile =>
({
@@ -133,6 +152,10 @@ afterEach(() => {
mocks.confirm.mockClear();
mocks.keys = [];
mocks.fetching = false;
mocks.titleArgs = [];
mocks.wideLoading = false;
mocks.notify.mockClear();
resetQueueWatch();
});
function mount(
@@ -382,6 +405,26 @@ describe("Files · my file's row", () => {
fireEvent.click(menuItem("files.actions.delete"));
await waitFor(() => expect(del).toHaveBeenCalledWith(2));
});
it("a deleted file leaves the tray's finished list: its Open would reach nothing", async () => {
mocks.files = [file(2)];
mocks.folders = [YT];
vi.spyOn(api, "deleteFile").mockResolvedValue({} as never);
const job = (status: string) => ({ id: 2, status }) as unknown as QueueJob;
noteQueue([job("running")]);
noteQueue([job("done")]);
let finished: number[] = [];
function Tray() {
finished = useFinished().map((j) => j.id);
return null;
}
render(<Tray />);
expect(finished).toEqual([2]);
mount();
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
fireEvent.click(menuItem("files.actions.delete"));
await waitFor(() => expect(finished).toEqual([]));
});
});
describe("Files · a library past the list's cap", () => {
@@ -406,6 +449,83 @@ describe("Files · a library past the list's cap", () => {
expect(rowOf("Régi recept")).toBeTruthy();
expect(mocks.keys).toContainEqual(["files", { q: "recept" }]);
});
const capped = () => Array.from({ length: FILES_LIST_CAP }, (_, i) => file(i + 1));
const nameHeader = () => screen.getByRole("button", { name: /files\.columns\.name/ });
it("a sort asked of the server keeps the table on screen until its answer is in", () => {
mocks.folders = [YT];
mocks.files = capped();
mocks.wideLoading = true;
mount();
const header = nameHeader();
fireEvent.click(header);
// Not a skeleton in its place: that would take the header just pressed, and the keyboard with it.
expect(header.isConnected).toBe(true);
expect(rowOf("Clip 1")).toBeTruthy();
expect(screen.getByText("files.countNewest")).toBeTruthy();
});
it("says a list the server's sort cut holds only its first files; a short search is whole", () => {
mocks.folders = [YT];
mocks.files = capped();
mocks.searched = Array.from({ length: FILES_LIST_CAP }, (_, i) => file(i + 1000));
mount();
expect(screen.getByText("files.countNewest")).toBeTruthy();
fireEvent.click(nameHeader());
expect(screen.getByText("files.countFirst")).toBeTruthy();
cleanup();
mocks.searched = [file(9999, { display_name: "Régi recept" })];
mount(ALL_FILES, vi.fn(), "recept");
expect(screen.getByText("files.count")).toBeTruthy();
});
it("an arrival waits for the server's answer: the table can only turn to a file it holds", async () => {
mocks.folders = [YT];
mocks.files = capped();
mocks.wideLoading = true;
const consumeFocus = vi.fn();
mount(ALL_FILES, vi.fn(), "clip", { focus: { id: 3, token: 1 }, consumeFocus });
await new Promise((r) => setTimeout(r, 20));
expect(consumeFocus).not.toHaveBeenCalled();
});
it("a file past the end of a cut list is not said to be gone — it may only be older", async () => {
mocks.folders = [YT];
mocks.files = capped();
mount(ALL_FILES, vi.fn(), "", { focus: { id: 9999, token: 1 } });
await waitFor(() =>
expect(mocks.notify).toHaveBeenCalledWith({
level: "info",
message: "files.arrival.notListed",
}),
);
expect(mocks.notify).not.toHaveBeenCalledWith({ level: "info", message: "files.arrival.gone" });
});
it("an arrival's cleared search is asked of the server at once, not after the typing pause", () => {
mocks.folders = [YT];
mocks.files = capped();
mocks.searched = [];
const view = (search: string, focus: FilesFocus | null) => (
<QueryClientProvider client={new QueryClient()}>
<FilesActionsContext.Provider
value={{ setSearch: vi.fn(), setView: vi.fn(), openFile: vi.fn(), consumeFocus: vi.fn() }}
>
<FilesStateContext.Provider value={{ search, view: ALL_FILES, focus, arrived: null }}>
<Files />
</FilesStateContext.Provider>
</FilesActionsContext.Provider>
</QueryClientProvider>
);
const r = render(view("recept", null));
expect(mocks.keys).toContainEqual(["files", { q: "recept" }]);
mocks.keys = [];
// openFile: the search cleared and an arrival pending, in one step.
r.rerender(view("", { id: 3, token: 1 }));
expect(mocks.keys).not.toContainEqual(["files", { q: "recept" }]);
});
});
describe("Files · the confirms", () => {
@@ -429,6 +549,10 @@ describe("Files · the confirms", () => {
["downloads.confirm.redownloadTitle", "downloads.confirm.redownloadAction"],
["downloads.confirm.deleteTitle", "downloads.actions.delete"],
]);
expect(mocks.titleArgs).toContainEqual([
"downloads.confirm.redownloadTitle",
{ name: "Clip 2" },
]);
mocks.confirm.mockResolvedValue(true);
});
@@ -468,6 +592,10 @@ describe("Files · a shared file's row", () => {
]);
fireEvent.click(menuItem("files.actions.removeShared"));
await waitFor(() => expect(remove).toHaveBeenCalledWith(4));
expect(mocks.titleArgs).toContainEqual([
"downloads.confirm.removeSharedTitle",
{ name: "Clip 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();
@@ -879,9 +1007,27 @@ describe("Files · dropping a file on the page", () => {
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);
const beside = drag("dragover");
expect(beside.defaultPrevented).toBe(true);
expect((beside as DragEvent).dataTransfer?.dropEffect).toBe("none");
expect(drag("drop", [new File(["y"], "masik.pdf")]).defaultPrevented).toBe(true);
});
it("the open Upload dialog's own zone keeps the drop it accepted", 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")]);
const zone = (await screen.findByText("recept.pdf")).closest("button")!;
const over = createEvent.dragOver(zone, {
dataTransfer: { types: ["Files"], files: [], dropEffect: "copy" },
}) as DragEvent;
fireEvent(zone, over);
// A "none" written by the page's own listener (it runs last) would refuse the zone's drop.
expect(over.defaultPrevented).toBe(true);
expect(over.dataTransfer?.dropEffect).toBe("copy");
});
});
describe("Files · when what it needs is missing", () => {