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
@@ -16,6 +16,7 @@ const h = vi.hoisted(() => ({
filesUsage: vi.fn(async () => ({ upload_max_bytes: 1_000_000 })),
notify: vi.fn(),
navigateTo: vi.fn(),
openFile: vi.fn(),
}));
vi.mock("react-i18next", async (importOriginal) => {
@@ -37,12 +38,25 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
vi.mock("../lib/nav", () => ({ navigateTo: h.navigateTo }));
import UploadDialog from "./UploadDialog";
import { FilesActionsContext } from "./filesContext";
const wrap = (onClose = vi.fn(), folderId: number | null = null) => {
const wrap = (
onClose = vi.fn(),
folderId: number | null = null,
initialFile: File | null = null,
) => {
const qc = new QueryClient();
const files = {
setSearch: vi.fn(),
setView: vi.fn(),
openFile: h.openFile,
consumeFocus: vi.fn(),
};
return render(
<QueryClientProvider client={qc}>
<UploadDialog folderId={folderId} onClose={onClose} />
<FilesActionsContext.Provider value={files}>
<UploadDialog folderId={folderId} initialFile={initialFile} onClose={onClose} />
</FilesActionsContext.Provider>
</QueryClientProvider>,
);
};
@@ -52,6 +66,8 @@ const fileInput = () => document.querySelector('input[type="file"]') as HTMLInpu
beforeEach(() => {
h.uploadFile.mockClear();
h.openFile.mockClear();
h.navigateTo.mockClear();
h.notify.mockClear();
h.filesUsage.mockClear();
h.filesUsage.mockResolvedValue({ upload_max_bytes: 1_000_000 });
@@ -99,14 +115,17 @@ describe("UploadDialog", () => {
});
it("puts the file in the folder it was opened from, and its notice opens where it now is", async () => {
// Done: the file is in Files. Still converting: it is in the download queue until it is done.
// Done: Files opens on the file itself, in its folder. Still converting: it is in the download
// queue until it is done.
h.uploadFile.mockResolvedValueOnce({ id: 42, status: "done", folder_id: 5 } as never);
wrap(vi.fn(), 5);
fireEvent.change(fileInput(), { target: { files: [new File(["x"], "a.mp4")] } });
fireEvent.click(screen.getByText("downloads.upload.submit"));
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
expect((h.uploadFile.mock.calls[0]![1] as { folderId?: number }).folderId).toBe(5);
(h.notify.mock.calls[0]![0] as { action: { onClick: () => void } }).action.onClick();
expect(h.navigateTo).toHaveBeenLastCalledWith("files");
expect(h.openFile).toHaveBeenLastCalledWith(42, 5);
expect(h.navigateTo).not.toHaveBeenCalled();
cleanup();
h.notify.mockClear();
@@ -119,6 +138,17 @@ describe("UploadDialog", () => {
expect(h.navigateTo).toHaveBeenLastCalledWith("downloads");
});
it("opens ready to send a file dropped on the page", async () => {
const dropped = new File(["dropped"], "recept.pdf");
wrap(vi.fn(), null, dropped);
expect(screen.getByText("recept.pdf")).toBeTruthy();
const btn = screen.getByText("downloads.upload.submit").closest("button")!;
expect(btn.disabled).toBe(false);
fireEvent.click(btn);
await waitFor(() => expect(h.uploadFile).toHaveBeenCalledTimes(1));
expect(h.uploadFile.mock.calls[0]![0]).toBe(dropped);
});
it("keeps Upload disabled until a file is chosen", () => {
wrap();
const btn = screen.getByText("downloads.upload.submit").closest("button")!;