feat(files): the Files page — the library in one aligned list, its folders in a rail
Files is the one library: every done file, the ones shared with me, or one folder's, picked in a side rail (sources, own folders, the storage meter) and mirrored to ?folder=. One table with every kind of value in its own column; Copy link, Share and Save in sight, every action on a right click: play, cut, rename in the row, move to an own folder or back home, keep, details, re-download, delete. The Downloads page keeps only the queue until the tray replaces it. The old /api/downloads library routes are gone: list shared, upload, the details PATCH, delete, the file and the usage now live under /api/files, and a listed file says which source it came from. The job's "expired" flag is gone with the state it described.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The local-upload dialog. It's a general file upload (any type, no format list): a picked
|
||||
// file surfaces its name+size, and Upload streams it through api.uploadDownload (the XHR-progress
|
||||
// file surfaces its name+size, and Upload streams it through api.uploadFile (the XHR-progress
|
||||
// path). This drives the real dialog against a mocked api so we can assert the file + optional name
|
||||
// actually reach the ingest call, and that the success toast points at the library. i18n is
|
||||
// key-passthrough.
|
||||
@@ -10,11 +10,12 @@ import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/re
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
uploadDownload: vi.fn(async (_f: File, _o: unknown) => ({ id: 42, status: "done" })),
|
||||
uploadFile: vi.fn(async (_f: File, _o: unknown) => ({ id: 42, status: "done" })),
|
||||
// The dialog reads the per-file upload cap so it can refuse an over-cap file before spending the
|
||||
// user's bandwidth on it. Generous by default; the cap test below lowers it.
|
||||
downloadUsage: vi.fn(async () => ({ upload_max_bytes: 1_000_000 })),
|
||||
filesUsage: vi.fn(async () => ({ upload_max_bytes: 1_000_000 })),
|
||||
notify: vi.fn(),
|
||||
navigateTo: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
@@ -25,7 +26,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
vi.mock("../lib/api", () => ({
|
||||
api: { uploadDownload: h.uploadDownload, downloadUsage: h.downloadUsage },
|
||||
api: { uploadFile: h.uploadFile, filesUsage: h.filesUsage },
|
||||
}));
|
||||
vi.mock("../lib/downloads", () => ({ invalidateDownloads: vi.fn() }));
|
||||
// The rest of the module stays real: the dialog is a ui/Modal, which hosts its own toasts.
|
||||
@@ -33,15 +34,15 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../lib/notifications")>()),
|
||||
notify: h.notify,
|
||||
}));
|
||||
vi.mock("../lib/nav", () => ({ navigateTo: vi.fn() }));
|
||||
vi.mock("../lib/nav", () => ({ navigateTo: h.navigateTo }));
|
||||
|
||||
import UploadDialog from "./UploadDialog";
|
||||
|
||||
const wrap = (onClose = vi.fn()) => {
|
||||
const wrap = (onClose = vi.fn(), folderId: number | null = null) => {
|
||||
const qc = new QueryClient();
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<UploadDialog onClose={onClose} />
|
||||
<UploadDialog folderId={folderId} onClose={onClose} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
@@ -50,10 +51,10 @@ const wrap = (onClose = vi.fn()) => {
|
||||
const fileInput = () => document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
|
||||
beforeEach(() => {
|
||||
h.uploadDownload.mockClear();
|
||||
h.uploadFile.mockClear();
|
||||
h.notify.mockClear();
|
||||
h.downloadUsage.mockClear();
|
||||
h.downloadUsage.mockResolvedValue({ upload_max_bytes: 1_000_000 });
|
||||
h.filesUsage.mockClear();
|
||||
h.filesUsage.mockResolvedValue({ upload_max_bytes: 1_000_000 });
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
@@ -73,8 +74,8 @@ describe("UploadDialog", () => {
|
||||
|
||||
fireEvent.click(screen.getByText("downloads.upload.submit"));
|
||||
|
||||
await waitFor(() => expect(h.uploadDownload).toHaveBeenCalledTimes(1));
|
||||
const [passedFile, opts] = h.uploadDownload.mock.calls[0]!;
|
||||
await waitFor(() => expect(h.uploadFile).toHaveBeenCalledTimes(1));
|
||||
const [passedFile, opts] = h.uploadFile.mock.calls[0]!;
|
||||
expect(passedFile).toBe(f);
|
||||
expect((opts as { displayName?: string }).displayName).toBe("My notes");
|
||||
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
|
||||
@@ -85,7 +86,7 @@ describe("UploadDialog", () => {
|
||||
it("says so when the server queued the upload for its phone-safe conversion", async () => {
|
||||
// A video no phone could play as it is (an iPhone's HEVC .mov) comes back `queued`, not `done`:
|
||||
// "File uploaded" alone would send the user to a Library row that is still converting.
|
||||
h.uploadDownload.mockResolvedValueOnce({ id: 43, status: "queued" });
|
||||
h.uploadFile.mockResolvedValueOnce({ id: 43, status: "queued" });
|
||||
wrap();
|
||||
fireEvent.change(fileInput(), {
|
||||
target: { files: [new File(["x"], "clip.mov", { type: "video/quicktime" })] },
|
||||
@@ -97,6 +98,27 @@ 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.
|
||||
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");
|
||||
|
||||
cleanup();
|
||||
h.notify.mockClear();
|
||||
h.uploadFile.mockResolvedValueOnce({ id: 43, status: "queued" });
|
||||
wrap();
|
||||
fireEvent.change(fileInput(), { target: { files: [new File(["x"], "b.mov")] } });
|
||||
fireEvent.click(screen.getByText("downloads.upload.submit"));
|
||||
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
|
||||
(h.notify.mock.calls[0]![0] as { action: { onClick: () => void } }).action.onClick();
|
||||
expect(h.navigateTo).toHaveBeenLastCalledWith("downloads");
|
||||
});
|
||||
|
||||
it("keeps Upload disabled until a file is chosen", () => {
|
||||
wrap();
|
||||
const btn = screen.getByText("downloads.upload.submit").closest("button")!;
|
||||
@@ -118,9 +140,9 @@ describe("UploadDialog", () => {
|
||||
// answers is the edge ceiling, whose refusal carries no number at all and may not even reach
|
||||
// the browser. So the dialog checks the size itself: the user learns the limit before a byte
|
||||
// moves.
|
||||
h.downloadUsage.mockResolvedValue({ upload_max_bytes: CAP });
|
||||
h.filesUsage.mockResolvedValue({ upload_max_bytes: CAP });
|
||||
wrap();
|
||||
await waitFor(() => expect(h.downloadUsage).toHaveBeenCalled());
|
||||
await waitFor(() => expect(h.filesUsage).toHaveBeenCalled());
|
||||
pickFile(CAP + 1, "big.bin");
|
||||
|
||||
await waitFor(() => expect(screen.getByText("downloads.errors.upload.tooLarge")).toBeTruthy());
|
||||
@@ -139,9 +161,9 @@ describe("UploadDialog", () => {
|
||||
// the only feedback that a drag is over a live target — and a danger border that outranks the
|
||||
// drag state reads as "drag-and-drop is off now", sending the user hunting for a control that
|
||||
// does not exist. The drop would have worked.
|
||||
h.downloadUsage.mockResolvedValue({ upload_max_bytes: CAP });
|
||||
h.filesUsage.mockResolvedValue({ upload_max_bytes: CAP });
|
||||
wrap();
|
||||
await waitFor(() => expect(h.downloadUsage).toHaveBeenCalled());
|
||||
await waitFor(() => expect(h.filesUsage).toHaveBeenCalled());
|
||||
pickFile(CAP + 1, "big.bin");
|
||||
const zone = screen.getByText("big.bin").closest("button")!;
|
||||
expect(zone.className).toContain("border-danger");
|
||||
@@ -159,9 +181,9 @@ describe("UploadDialog", () => {
|
||||
// `submit()` changes nothing here, which is why that term is documented as belt and braces
|
||||
// rather than as the mechanism. What this pins is the user-visible contract: pressing Upload
|
||||
// after dropping a bigger file does not start a transfer the server will refuse.
|
||||
h.downloadUsage.mockResolvedValue({ upload_max_bytes: CAP });
|
||||
h.filesUsage.mockResolvedValue({ upload_max_bytes: CAP });
|
||||
wrap();
|
||||
await waitFor(() => expect(h.downloadUsage).toHaveBeenCalled());
|
||||
await waitFor(() => expect(h.filesUsage).toHaveBeenCalled());
|
||||
pickFile(CAP, "fits.bin");
|
||||
const btn = screen.getByText("downloads.upload.submit").closest("button")!;
|
||||
btn.focus();
|
||||
@@ -170,17 +192,17 @@ describe("UploadDialog", () => {
|
||||
await waitFor(() => expect(screen.getByRole("alert")).toBeTruthy());
|
||||
expect(btn.disabled).toBe(false); // soft-disabled, still focusable — and still clickable
|
||||
fireEvent.click(btn);
|
||||
expect(h.uploadDownload).not.toHaveBeenCalled();
|
||||
expect(h.uploadFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends a file of EXACTLY the cap, which the server accepts", async () => {
|
||||
h.downloadUsage.mockResolvedValue({ upload_max_bytes: CAP });
|
||||
h.filesUsage.mockResolvedValue({ upload_max_bytes: CAP });
|
||||
wrap();
|
||||
await waitFor(() => expect(h.downloadUsage).toHaveBeenCalled());
|
||||
await waitFor(() => expect(h.filesUsage).toHaveBeenCalled());
|
||||
pickFile(CAP, "exact.bin");
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
fireEvent.click(screen.getByText("downloads.upload.submit"));
|
||||
await waitFor(() => expect(h.uploadDownload).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(h.uploadFile).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("re-announces the refusal when a SECOND over-cap file is picked", async () => {
|
||||
@@ -188,9 +210,9 @@ describe("UploadDialog", () => {
|
||||
// file, so two over-cap picks leave the node and its text identical — and a live region that
|
||||
// is never touched says nothing. See formAlerts.dom.test.tsx, which holds the same rule for
|
||||
// the three other alerts in this app that can repeat.
|
||||
h.downloadUsage.mockResolvedValue({ upload_max_bytes: CAP });
|
||||
h.filesUsage.mockResolvedValue({ upload_max_bytes: CAP });
|
||||
wrap();
|
||||
await waitFor(() => expect(h.downloadUsage).toHaveBeenCalled());
|
||||
await waitFor(() => expect(h.filesUsage).toHaveBeenCalled());
|
||||
pickFile(CAP + 1, "a.bin");
|
||||
const first = await screen.findByRole("alert");
|
||||
pickFile(CAP + 2, "b.bin");
|
||||
|
||||
Reference in New Issue
Block a user