feat(files): a file that is not media opens in a viewer — an image, a PDF, text as text
- GET /api/files/{id}/preview: an allow-list on the stored extension (downloads/preview.py) picks
image / PDF / text; served inline under `CSP: sandbox; frame-ancestors 'self'`, the type from the
table; HTML and SVG arrive as plain text; a text is its first 1 MiB (X-Preview-Truncated), sent
as UTF-8 whatever it was written in (BOM, UTF-8, else cp1250); a NUL means binary: 415
- the list says which preview each file gets (asset.preview); FE/BE contract on kinds and header
- the viewer (shared dialog shell): ← / → through the list's such files in its order, Save, open in
a tab, the file's menu; one stage height for every kind; a no-preview card offers the file
- the name, the card's picture and the menu's Preview open it; an image up to 8 MB is its own
thumbnail
This commit is contained in:
@@ -402,6 +402,62 @@ describe("Files · a shared file's row", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Files · the viewer", () => {
|
||||
const doc = (id: number, preview: string | null = "pdf") =>
|
||||
file(id, {
|
||||
job_kind: "upload",
|
||||
display_name: `Doc ${id}`,
|
||||
asset: { media_kind: "file", preview, container: "pdf", size_bytes: 10 },
|
||||
} as never);
|
||||
const viewer = () => screen.findByRole("dialog");
|
||||
|
||||
it("opens a file that is not media from its name, in the sandboxed preview", async () => {
|
||||
mocks.files = [file(1), doc(2)];
|
||||
mocks.folders = [YT];
|
||||
mount();
|
||||
fireEvent.click(screen.getByText("Doc 2"));
|
||||
const dialog = await viewer();
|
||||
expect(dialog.querySelector("iframe")!.getAttribute("src")).toBe(api.previewUrl(2));
|
||||
});
|
||||
|
||||
it("puts Preview first in such a file's menu, and it opens the viewer", async () => {
|
||||
mocks.files = [doc(2, null)];
|
||||
mocks.folders = [YT];
|
||||
mount();
|
||||
await openMenu(() => fireEvent.contextMenu(rowOf("Doc 2")));
|
||||
expect(menuRows()[0]).toBe("files.actions.view");
|
||||
fireEvent.click(menuItem("files.actions.view"));
|
||||
expect(await viewer()).toBeTruthy();
|
||||
expect(screen.getByText("files.preview.none")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("steps through the list's files that are not media, in the list's order", async () => {
|
||||
mocks.files = [doc(2), file(1), doc(3)];
|
||||
mocks.folders = [YT];
|
||||
mount();
|
||||
fireEvent.click(screen.getByText("Doc 2"));
|
||||
const dialog = await viewer();
|
||||
fireEvent.keyDown(window, { key: "ArrowRight" });
|
||||
await waitFor(() =>
|
||||
expect(dialog.querySelector("iframe")!.getAttribute("src")).toBe(api.previewUrl(3)),
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves Preview and Rename out of the viewer's own menu", async () => {
|
||||
mocks.files = [doc(2)];
|
||||
mocks.folders = [YT, OWN];
|
||||
mount();
|
||||
fireEvent.click(screen.getByText("Doc 2"));
|
||||
const dialog = await viewer();
|
||||
await openMenu(() =>
|
||||
fireEvent.click(dialog.querySelector<HTMLElement>('button[aria-label="files.rowMenu"]')!),
|
||||
);
|
||||
expect(menuRows()).not.toContain("files.actions.view");
|
||||
expect(menuRows()).not.toContain("files.actions.rename");
|
||||
expect(menuRows()).toContain("files.actions.move");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Files · the view", () => {
|
||||
it("starts the list at the scroller's top, so its sticky header does not jump on the first scroll", () => {
|
||||
mocks.files = [file(2)];
|
||||
|
||||
Reference in New Issue
Block a user