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:
@@ -0,0 +1,157 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The Files rail's folder management: a new folder is named in place, a folder's right click opens its
|
||||
// ONE menu (the page's "…" opens the same — Files.menu.dom.test), and a rename happens in the entry.
|
||||
// A field left by Enter or Escape hands the keyboard back to what stands there now, never to <body>.
|
||||
// The SidePanel chrome around the groups is stubbed; the writes are watched at the API.
|
||||
import { afterEach, beforeAll, 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 type { ReactNode } from "react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }) };
|
||||
});
|
||||
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => false }));
|
||||
vi.mock("./FilterRail", () => ({
|
||||
default: ({
|
||||
items,
|
||||
children,
|
||||
}: {
|
||||
items: { id: string; body: ReactNode }[];
|
||||
children: ReactNode;
|
||||
}) => (
|
||||
<>
|
||||
{items.map((i) => (
|
||||
<div key={i.id}>{i.body}</div>
|
||||
))}
|
||||
{children}
|
||||
</>
|
||||
),
|
||||
}));
|
||||
|
||||
import FilesRail from "./FilesRail";
|
||||
import { api, type FileFolder } from "../lib/api";
|
||||
import { defaultLayout } from "../lib/panelLayout";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const folder = (id: number, extra: Partial<FileFolder>): FileFolder => ({
|
||||
id,
|
||||
kind: "source",
|
||||
source_key: null,
|
||||
name: null,
|
||||
retention_days: 30,
|
||||
count: 1,
|
||||
size_bytes: 0,
|
||||
created_at: null,
|
||||
...extra,
|
||||
});
|
||||
const YT = folder(1, { source_key: "youtube" });
|
||||
const OWN = folder(2, { kind: "own", name: "Receptek", retention_days: null });
|
||||
|
||||
async function mount(folders: FileFolder[] = [YT, OWN]) {
|
||||
vi.spyOn(api, "fileFolders").mockResolvedValue(folders);
|
||||
vi.spyOn(api, "filesUsage").mockResolvedValue({ unlimited: true, footprint_bytes: 0 } as never);
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<FilesRail
|
||||
layout={defaultLayout("files")}
|
||||
setLayout={vi.fn()}
|
||||
collapsed={false}
|
||||
onToggleCollapse={vi.fn()}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await screen.findByText("files.folder.new");
|
||||
}
|
||||
|
||||
const entry = (name: string) => screen.getByText(name).closest<HTMLElement>("button")!;
|
||||
const menuRows = () =>
|
||||
screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent ?? "");
|
||||
|
||||
describe("FilesRail · folders", () => {
|
||||
it("names a new folder in place; Enter makes it and gives the keyboard back", async () => {
|
||||
await mount();
|
||||
const create = vi.spyOn(api, "createFolder").mockResolvedValue(folder(3, { kind: "own" }));
|
||||
fireEvent.click(screen.getByText("files.folder.new"));
|
||||
const field = screen.getByRole("textbox", { name: "files.folder.newName" });
|
||||
fireEvent.change(field, { target: { value: " Cimboráknak " } });
|
||||
fireEvent.keyDown(field, { key: "Enter" });
|
||||
await waitFor(() => expect(create).toHaveBeenCalledWith("Cimboráknak"));
|
||||
await waitFor(() =>
|
||||
expect(document.activeElement).toBe(screen.getByText("files.folder.new").closest("button")),
|
||||
);
|
||||
});
|
||||
|
||||
it("Escape makes nothing; an empty name makes nothing", async () => {
|
||||
await mount();
|
||||
const create = vi.spyOn(api, "createFolder");
|
||||
fireEvent.click(screen.getByText("files.folder.new"));
|
||||
const field = screen.getByRole("textbox", { name: "files.folder.newName" });
|
||||
fireEvent.change(field, { target: { value: "Más" } });
|
||||
fireEvent.keyDown(field, { key: "Escape" });
|
||||
expect(screen.queryByRole("textbox")).toBeNull();
|
||||
fireEvent.click(screen.getByText("files.folder.new"));
|
||||
fireEvent.keyDown(screen.getByRole("textbox", { name: "files.folder.newName" }), {
|
||||
key: "Enter",
|
||||
});
|
||||
await act(async () => {});
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a folder's right click opens its menu; a source folder's holds only its retention", async () => {
|
||||
await mount();
|
||||
act(() => {
|
||||
fireEvent.contextMenu(entry("Receptek"));
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(menuRows()).toEqual([
|
||||
"files.folder.rename",
|
||||
"files.folder.retention",
|
||||
"files.folder.delete",
|
||||
]),
|
||||
);
|
||||
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
|
||||
act(() => {
|
||||
fireEvent.contextMenu(entry("files.source.youtube"));
|
||||
});
|
||||
await waitFor(() => expect(menuRows()).toEqual(["files.folder.retention"]));
|
||||
});
|
||||
|
||||
it("renames in the entry, and Enter hands the keyboard back to it", async () => {
|
||||
await mount();
|
||||
const update = vi.spyOn(api, "updateFolder").mockResolvedValue(OWN);
|
||||
act(() => {
|
||||
fireEvent.contextMenu(entry("Receptek"));
|
||||
});
|
||||
await waitFor(() => expect(menuRows()).toContain("files.folder.rename"));
|
||||
fireEvent.click(
|
||||
screen
|
||||
.getAllByRole("menuitem", { hidden: true })
|
||||
.find((b) => b.textContent === "files.folder.rename")!,
|
||||
);
|
||||
const field = await screen.findByRole("textbox", { name: "files.folder.rename" });
|
||||
fireEvent.change(field, { target: { value: "Ételek" } });
|
||||
fireEvent.keyDown(field, { key: "Enter" });
|
||||
await waitFor(() => expect(update).toHaveBeenCalledWith(2, { name: "Ételek" }));
|
||||
await waitFor(() => expect(document.activeElement).toBe(entry("Receptek")));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user