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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user