refactor(frontend): the flat components/ folder moves into shell/, modules/<page>/ and features/<name>/

A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform
layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each
page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in
features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/
(ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows).

Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the
contract tests that scan sources now scan the whole app source, not only components/ — each glob
was counted before and after so none went green over fewer files. The locale-format rule matched
`/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton
check; it now covers every folder that holds components, with a test per folder.
This commit is contained in:
2026-10-22 23:12:45 +02:00
parent fdf8076013
commit 63a0fecc8b
356 changed files with 2433 additions and 2332 deletions
@@ -0,0 +1,223 @@
// @vitest-environment jsdom
//
// The Files viewer: what each kind of file shows (an image, a PDF in a frame, text as text — HTML too,
// never a page — or the "no preview" card), ← / → through the files it was given in their order,
// and closing when the file it shows leaves the list. i18n is key-passthrough.
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, HttpError, type LibraryFile } from "../../lib/api";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
i18n: { language: "en" },
t: (key: string, o?: Record<string, unknown>) =>
o && "n" in o ? `${key}:${String(o.n)}/${String(o.total)}` : key,
}),
};
});
import FilePreview from "./FilePreview";
import { pushLayer, removeLayer } from "../../lib/layerStack";
const file = (id: number, preview: string | null, extra: Record<string, unknown> = {}) =>
({
id,
status: "done",
display_name: `File ${id}`,
can_download: true,
job_kind: "upload",
shared: false,
source_key: "upload",
asset: { media_kind: "file", preview, container: "x", size_bytes: 4096, ...extra },
}) as unknown as LibraryFile;
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
function mount(files: LibraryFile[], fileId: number, onClose = vi.fn()) {
const onFileChange = vi.fn();
const view = (list: LibraryFile[], id: number) => (
<QueryClientProvider client={new QueryClient()}>
<FilePreview
files={list}
fileId={id}
onFileChange={onFileChange}
onClose={onClose}
menuItems={() => []}
/>
</QueryClientProvider>
);
const r = render(view(files, fileId));
return {
onFileChange,
onClose,
rerender: (l: LibraryFile[], id: number) => r.rerender(view(l, id)),
};
}
describe("FilePreview · what it shows", () => {
it("an image as an image, from the sandboxed preview", () => {
mount([file(1, "image")], 1);
const img = screen.getByRole("img", { name: "File 1" });
expect(img.getAttribute("src")).toBe(api.previewUrl(1));
});
it("a PDF in a frame of the preview, the browser's own viewer", () => {
mount([file(2, "pdf")], 2);
const frame = document.querySelector("iframe")!;
expect(frame.getAttribute("src")).toBe(api.previewUrl(2));
expect(frame.title).toBe("File 2");
});
it("a page's source as text, line by line — never as a page", async () => {
vi.spyOn(api, "previewText").mockResolvedValue({
text: "<h1 id=x>hi</h1>\n<script>boom()</script>",
truncated: false,
});
mount([file(3, "text")], 3);
await screen.findByText("<h1 id=x>hi</h1>");
expect(screen.getByText("<script>boom()</script>")).toBeTruthy();
expect(document.getElementById("x")).toBeNull();
expect(document.querySelector("script")).toBeNull();
expect(screen.getByText("files.preview.asText")).toBeTruthy();
});
it("numbers a file's lines, the newline that ends it making no line of its own", async () => {
vi.spyOn(api, "previewText").mockResolvedValue({ text: "one\r\ntwo\n", truncated: false });
mount([file(8, "text")], 8);
await screen.findByText("one");
expect([...document.querySelectorAll("li")].map((li) => li.textContent)).toEqual([
"1one",
"2two",
]);
});
it("lays out a long text's first lines only, and says where the rest is", async () => {
// Every line is a row of the page: a log of tens of thousands would stall the viewer.
const text = Array.from({ length: 6000 }, (_, i) => `line ${i + 1}`).join(
String.fromCharCode(10),
);
vi.spyOn(api, "previewText").mockResolvedValue({ text, truncated: false });
mount([file(9, "text")], 9);
await screen.findByText("line 1");
expect(document.querySelectorAll("li")).toHaveLength(5000);
expect(screen.getByText(/files\.preview\.firstLines/)).toBeTruthy();
expect(screen.getByText("files.preview.downloadWhole").closest("a")!.getAttribute("href")).toBe(
api.fileUrl(9),
);
});
it("says when it shows only the start of a long text", async () => {
vi.spyOn(api, "previewText").mockResolvedValue({ text: "a", truncated: true });
mount([file(4, "text")], 4);
expect(await screen.findByText("files.preview.truncated")).toBeTruthy();
});
it("a text the server refuses (binary after all) gets the no-preview card, not an error", async () => {
vi.spyOn(api, "previewText").mockRejectedValue(new HttpError(415, "x", {}));
mount([file(5, "text")], 5);
expect(await screen.findByText("files.preview.none")).toBeTruthy();
expect(screen.queryByRole("alert")).toBeNull();
// A new tab would only show the refusal.
expect(screen.queryByRole("link", { name: "files.preview.openInTab" })).toBeNull();
});
it("a text that fails to load says so, with a retry", async () => {
vi.spyOn(api, "previewText").mockRejectedValue(new HttpError(500, "x", {}));
mount([file(6, "text")], 6);
expect(await screen.findByRole("alert", {}, { timeout: 3000 })).toBeTruthy();
expect(screen.getByRole("button", { name: "common.retry" })).toBeTruthy();
});
it("anything else: the card that offers the file itself", () => {
mount([file(7, null)], 7);
expect(screen.getByText("files.preview.none")).toBeTruthy();
const save = screen.getAllByRole("link", { name: "downloads.actions.download" });
expect(save.every((a) => a.getAttribute("href") === api.fileUrl(7))).toBe(true);
// Nothing to open in a tab: the preview would refuse it.
expect(screen.queryByRole("link", { name: "files.preview.openInTab" })).toBeNull();
});
});
describe("FilePreview · stepping", () => {
const list = [file(1, "image"), file(2, "pdf"), file(3, null)];
it("← / → go to the files beside it, in the order given", () => {
const { onFileChange } = mount(list, 2);
expect(screen.getByText("files.preview.position:2/3")).toBeTruthy();
fireEvent.keyDown(window, { key: "ArrowRight" });
expect(onFileChange).toHaveBeenLastCalledWith(3);
fireEvent.keyDown(window, { key: "ArrowLeft" });
expect(onFileChange).toHaveBeenLastCalledWith(1);
});
it("a layer opened from it (a menu, a confirm) keeps its arrows — the viewer stays on its file", () => {
const { onFileChange } = mount(list, 2);
const confirm = pushLayer();
try {
fireEvent.keyDown(window, { key: "ArrowRight" });
expect(onFileChange).not.toHaveBeenCalled();
} finally {
removeLayer(confirm);
}
fireEvent.keyDown(window, { key: "ArrowRight" });
expect(onFileChange).toHaveBeenLastCalledWith(3);
});
it("a text line wider than the stage scrolls sideways under ← / →, it does not page", async () => {
vi.spyOn(api, "previewText").mockResolvedValue({ text: "a very long line", truncated: false });
const { onFileChange } = mount([file(1, "image"), file(8, "text"), file(9, "text")], 8);
await screen.findByText("a very long line");
const region = screen.getByRole("region");
Object.defineProperty(region, "scrollWidth", { value: 900 });
Object.defineProperty(region, "clientWidth", { value: 300 });
fireEvent.keyDown(region, { key: "ArrowRight" });
expect(onFileChange).not.toHaveBeenCalled();
});
it("a text whose lines fit pages under ← / →, even with the keyboard in it", async () => {
vi.spyOn(api, "previewText").mockResolvedValue({ text: "short", truncated: false });
const { onFileChange } = mount([file(1, "image"), file(8, "text"), file(9, "text")], 8);
await screen.findByText("short");
const region = screen.getByRole("region");
Object.defineProperty(region, "scrollWidth", { value: 300 });
Object.defineProperty(region, "clientWidth", { value: 300 });
fireEvent.keyDown(region, { key: "ArrowRight" });
expect(onFileChange).toHaveBeenLastCalledWith(9);
});
it("Escape closes it — after a layer opened above it has taken its own", () => {
const { onClose } = mount(list, 2);
const menu = pushLayer();
try {
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).not.toHaveBeenCalled();
} finally {
removeLayer(menu);
}
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalledTimes(1);
});
it("stops at either end", () => {
const { onFileChange } = mount(list, 1);
fireEvent.keyDown(window, { key: "ArrowLeft" });
expect(onFileChange).not.toHaveBeenCalled();
expect(
screen.getByRole("button", { name: "files.preview.prev" }).hasAttribute("disabled"),
).toBe(true);
});
it("closes when the file it shows leaves the list", async () => {
const { onClose, rerender } = mount(list, 2);
expect(onClose).not.toHaveBeenCalled();
act(() => rerender([list[0]!, list[2]!], 2));
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
});