feat(files): folders managed in place, a grid per folder, drop to upload, open on one file

- Own folders are made, renamed and deleted from the rail (right click, long press,
  Shift+F10) or the "…" beside an open folder's name; any folder's retention is a
  submenu (7/30/90 days, until deleted). Deleting confirms where its files go.
- List or grid per folder, sharing one sort; the grid is the one media card.
- A file dropped anywhere on the page opens Upload with it, bound for the folder
  on screen.
- A link (?item=) or the upload notice opens Files on the file: its page turned
  to, the keyboard on it, a ring round it.
- An in-place name field left by Enter or Escape hands the focus back to what
  replaced it (the row, the entry, the "…") instead of dropping it on <body>.
- The table's saved page size is per account.
This commit is contained in:
2026-10-11 15:59:12 +02:00
parent 85aa917b38
commit d22c64f4dd
23 changed files with 1569 additions and 175 deletions
+300 -4
View File
@@ -5,17 +5,25 @@
// 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 { useState } from "react";
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";
import {
ALL_FILES,
FilesActionsContext,
FilesStateContext,
type FilesFocus,
type FilesView,
} from "./filesContext";
const mocks = vi.hoisted(() => ({
files: [] as unknown[],
folders: [] as unknown[],
confirm: vi.fn(async (_o?: unknown) => true),
keys: [] as unknown[],
fetching: false,
}));
vi.mock("react-i18next", async (importOriginal) => {
@@ -38,6 +46,7 @@ vi.mock("../lib/useLiveQuery", () => ({
key[0] === "files" ? mocks.files : key[0] === "file-folders" ? mocks.folders : undefined,
isError: false,
isLoading: false,
isFetching: mocks.fetching,
refetch: vi.fn(),
};
},
@@ -92,13 +101,24 @@ afterEach(() => {
vi.restoreAllMocks();
mocks.confirm.mockClear();
mocks.keys = [];
mocks.fetching = false;
});
function mount(view: FilesView = ALL_FILES, setView = vi.fn(), search = "") {
function mount(
view: FilesView = ALL_FILES,
setView = vi.fn(),
search = "",
{
focus = null,
consumeFocus = vi.fn(),
}: { focus?: FilesFocus | null; consumeFocus?: () => void } = {},
) {
render(
<QueryClientProvider client={new QueryClient()}>
<FilesActionsContext.Provider value={{ setSearch: vi.fn(), setView }}>
<FilesStateContext.Provider value={{ search, view }}>
<FilesActionsContext.Provider
value={{ setSearch: vi.fn(), setView, openFile: vi.fn(), consumeFocus }}
>
<FilesStateContext.Provider value={{ search, view, focus, arrived: null }}>
<Files />
</FilesStateContext.Provider>
</FilesActionsContext.Provider>
@@ -218,6 +238,8 @@ describe("Files · my file's row", () => {
fireEvent.keyDown(field, { key: "Enter" });
await waitFor(() => expect(update).toHaveBeenCalledWith(2, { name: "Új név" }));
expect(screen.queryByRole("textbox", { name: "files.actions.rename" })).toBeNull();
// The keyboard is handed back to the row, not dropped on <body>.
await waitFor(() => expect(rowOf("Clip 2").contains(document.activeElement)).toBe(true));
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
fireEvent.click(menuItem("files.actions.rename"));
@@ -239,6 +261,7 @@ describe("Files · my file's row", () => {
fireEvent.keyDown(field, { key: "Escape" });
expect(screen.queryByRole("textbox")).toBeNull();
expect(update).not.toHaveBeenCalled();
await waitFor(() => expect(rowOf("Clip 2").contains(document.activeElement)).toBe(true));
});
it("moves to an own folder, and from there back to its source folder only", async () => {
@@ -395,3 +418,276 @@ describe("Files · the view", () => {
expect(screen.queryByText("Harmadik")).toBeNull();
});
});
describe("Files · the grid", () => {
// The layout is a pref, saved to the server; nothing here is about that.
const quiet = () => vi.spyOn(api, "savePrefs").mockResolvedValue({} as never);
async function pick(layout: "grid" | "list") {
fireEvent.click(screen.getByRole("button", { name: /files\.layout\.label/ }));
const options = await screen.findAllByRole("menuitemradio", { hidden: true });
fireEvent.click(options.find((o) => o.textContent === `files.layout.${layout}`)!);
}
it("paints the same files as cards, each with the same menu as its row", async () => {
quiet();
mocks.files = [file(2, { kept: true }), file(3)];
mocks.folders = [YT, OWN];
mount();
expect(rowOf("Clip 2").tagName).toBe("TR");
await pick("grid");
await waitFor(() => expect(rowOf("Clip 2").tagName).toBe("DIV"));
// Kept says so on its card.
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");
});
it("is remembered per folder", async () => {
quiet();
mocks.files = [file(2, { folder_id: 2 })];
mocks.folders = [YT, OWN];
// One mount whose view the test turns, as the rail would.
let turn: (v: FilesView) => void = () => {};
function Harness() {
const [view, setView] = useState<FilesView>(ALL_FILES);
turn = setView;
return (
<FilesActionsContext.Provider
value={{ setSearch: vi.fn(), setView, openFile: vi.fn(), consumeFocus: vi.fn() }}
>
<FilesStateContext.Provider value={{ search: "", view, focus: null, arrived: null }}>
<Files />
</FilesStateContext.Provider>
</FilesActionsContext.Provider>
);
}
render(
<QueryClientProvider client={new QueryClient()}>
<Harness />
</QueryClientProvider>,
);
await pick("grid");
await waitFor(() => expect(rowOf("Clip 2").tagName).toBe("DIV"));
act(() => turn({ kind: "folder", id: 2 }));
expect(rowOf("Clip 2").tagName).toBe("TR");
act(() => turn(ALL_FILES));
expect(rowOf("Clip 2").tagName).toBe("DIV");
});
});
describe("Files · the open folder's menu", () => {
const folderMenu = () => screen.getByRole("button", { name: "files.folder.menu" });
it("an own folder is renamed, kept for a while, or deleted", async () => {
mocks.files = [];
mocks.folders = [YT, OWN];
mount({ kind: "folder", id: 2 });
await openMenu(() => fireEvent.click(folderMenu()));
expect(menuRows()).toEqual([
"files.folder.rename",
"files.folder.retention",
"files.folder.delete",
]);
});
it("a source folder only keeps its files for a while", async () => {
mocks.files = [];
mocks.folders = [YT, OWN];
mount({ kind: "folder", id: 1 });
await openMenu(() => fireEvent.click(folderMenu()));
expect(menuRows()).toEqual(["files.folder.retention"]);
});
it("sets the retention, its current value ticked — an odd one in its place", async () => {
mocks.files = [];
mocks.folders = [folder(1, { source_key: "youtube", retention_days: 14 })];
const update = vi.spyOn(api, "updateFolder").mockResolvedValue({} as never);
mount({ kind: "folder", id: 1 });
await openMenu(() => fireEvent.click(folderMenu()));
fireEvent.keyDown(menuItem("files.folder.retention"), { key: "ArrowRight" });
await waitFor(() => expect(menuRows()).toContain("files.folder.forever"));
const choices = screen.queryAllByRole("menuitemradio", { hidden: true });
// 7, 14 (its own, ticked), 30, 90, forever.
expect(choices.map((c) => c.getAttribute("aria-checked"))).toEqual([
"false",
"true",
"false",
"false",
"false",
]);
fireEvent.click(menuItem("files.folder.forever"));
await waitFor(() => expect(update).toHaveBeenCalledWith(1, { retention_days: null }));
});
it("renames in the title: Enter saves, an unchanged name sends nothing", async () => {
mocks.files = [];
mocks.folders = [YT, OWN];
const update = vi.spyOn(api, "updateFolder").mockResolvedValue({} as never);
mount({ kind: "folder", id: 2 });
await openMenu(() => fireEvent.click(folderMenu()));
fireEvent.click(menuItem("files.folder.rename"));
const field = await screen.findByRole("textbox", { name: "files.folder.rename" });
fireEvent.keyDown(field, { key: "Enter" });
expect(update).not.toHaveBeenCalled();
// The keyboard goes back to the "…" the rename was opened from.
await waitFor(() => expect(document.activeElement).toBe(folderMenu()));
await openMenu(() => fireEvent.click(folderMenu()));
fireEvent.click(menuItem("files.folder.rename"));
const again = await screen.findByRole("textbox", { name: "files.folder.rename" });
fireEvent.change(again, { target: { value: " Receptek " } });
fireEvent.keyDown(again, { key: "Enter" });
await waitFor(() => expect(update).toHaveBeenCalledWith(2, { name: "Receptek" }));
// Only the changed name was sent: the first, unchanged Enter sent nothing.
expect(update).toHaveBeenCalledTimes(1);
});
it.each([
[3, "files.folder.deleteBody"],
[0, "files.folder.deleteBodyEmpty"],
])("deletes only through a confirm that says where its %i files go", async (count, body) => {
mocks.files = [];
mocks.folders = [YT, { ...OWN, count }];
const del = vi.spyOn(api, "deleteFolder").mockResolvedValue({} as never);
mocks.confirm.mockResolvedValueOnce(false);
mount({ kind: "folder", id: 2 });
await openMenu(() => fireEvent.click(folderMenu()));
fireEvent.click(menuItem("files.folder.delete"));
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
expect(del).not.toHaveBeenCalled();
const opts = mocks.confirm.mock.calls[0]![0] as Record<string, unknown>;
expect([opts.title, opts.message, opts.confirmLabel, opts.danger]).toEqual([
"files.folder.deleteTitle",
body,
"files.folder.deleteAction",
true,
]);
await openMenu(() => fireEvent.click(folderMenu()));
fireEvent.click(menuItem("files.folder.delete"));
await waitFor(() => expect(del).toHaveBeenCalledWith(2));
});
});
describe("Files · arriving at one file", () => {
const many = () => Array.from({ length: 60 }, (_, i) => file(i + 1));
// FilesProvider's arrival half: a pending focus, spent by the page into what it found.
function arriveAt(id: number, startView: FilesView = ALL_FILES) {
const seen = { setView: vi.fn(), consumed: [] as (string | null)[] };
function Harness() {
const [view, setViewState] = useState<FilesView>(startView);
const [focus, setFocus] = useState<FilesFocus | null>({ id, token: 1 });
const [arrived, setArrived] = useState<(FilesFocus & { view: string }) | null>(null);
const actions = {
setSearch: vi.fn(),
setView: (v: FilesView) => {
seen.setView(v);
setViewState(v);
},
openFile: vi.fn(),
consumeFocus: (at: string | null) => {
seen.consumed.push(at);
setArrived(focus && at !== null ? { ...focus, view: at } : null);
setFocus(null);
},
};
return (
<FilesActionsContext.Provider value={actions}>
<FilesStateContext.Provider value={{ search: "", view, focus, arrived }}>
<Files />
</FilesStateContext.Provider>
</FilesActionsContext.Provider>
);
}
render(
<QueryClientProvider client={new QueryClient()}>
<Harness />
</QueryClientProvider>,
);
return seen;
}
it("turns to the page that holds it, rings it and puts the keyboard on it", async () => {
mocks.files = many();
mocks.folders = [YT];
const seen = arriveAt(55);
// Page two of fifty: without the arrival it would not be on screen at all.
await waitFor(() => expect(screen.queryByText("Clip 55")).not.toBeNull());
expect(seen.consumed).toEqual(["all"]);
expect(rowOf("Clip 55").className).toContain("ring-accent");
expect(rowOf("Clip 54").className).not.toContain("ring-accent");
await waitFor(() => expect(rowOf("Clip 55").contains(document.activeElement)).toBe(true));
});
it("finds it among every file when the folder on screen does not hold it", async () => {
mocks.files = [file(2)];
mocks.folders = [YT, OWN];
const seen = arriveAt(2, { kind: "folder", id: 2 });
// The mock answers every view with the same rows, so "not in this folder" is staged by the
// folder's read: it is the all-files read that holds the file.
expect(seen.consumed).toEqual(["2"]);
expect(seen.setView).not.toHaveBeenCalled();
});
it("spends an arrival at a file that is nowhere, ringing nothing", async () => {
mocks.files = [file(2)];
mocks.folders = [YT];
const seen = arriveAt(9);
await waitFor(() => expect(seen.consumed).toEqual([null]));
expect(seen.setView).not.toHaveBeenCalled();
expect(rowOf("Clip 2").className).not.toContain("ring-accent");
});
it("waits while the list re-reads: a file just finished is not in the cached rows yet", async () => {
mocks.files = [file(2)];
mocks.folders = [YT];
mocks.fetching = true;
const seen = arriveAt(9);
await new Promise((r) => setTimeout(r, 20));
expect(seen.consumed).toEqual([]);
expect(seen.setView).not.toHaveBeenCalled();
});
it("looks in every file when this folder has no such file", async () => {
mocks.files = [file(2)];
mocks.folders = [YT, OWN];
const seen = arriveAt(9, { kind: "folder", id: 2 });
await waitFor(() => expect(seen.setView).toHaveBeenCalledWith(ALL_FILES));
// Then, among every file, it is nowhere either: spent.
await waitFor(() => expect(seen.consumed).toEqual([null]));
});
});
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;
};
it("names the folder it lands in, and opens Upload with the file", 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 });
// preventDefault is what makes the page a drop target at all.
expect(drag("dragenter").defaultPrevented).toBe(true);
expect(screen.getByText("files.drop.here:Cimboráknak")).toBeTruthy();
drag("drop", [new File(["x"], "recept.pdf")]);
expect(screen.queryByText("files.drop.here:Cimboráknak")).toBeNull();
expect(await screen.findByText("recept.pdf")).toBeTruthy();
});
it("is no target where nothing can be uploaded", () => {
mocks.files = [];
mocks.folders = [YT];
mount({ kind: "shared" });
expect(drag("dragenter").defaultPrevented).toBe(false);
expect(screen.queryByText(/files\.drop\.here/)).toBeNull();
});
});