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.
100 lines
3.8 KiB
TypeScript
100 lines
3.8 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// Locks the stacking that makes an admin config row usable on a phone. The
|
|
// control column cannot shrink, so beside a wide control it left the label and its hint a sliver of
|
|
// the ~250 px a phone leaves next to the navigation rail. The sibling fix — a long-labelled choice
|
|
// becoming a dropdown — is covered in ConfigPanel.choice.dom.test.tsx, but that one does nothing for
|
|
// a text, number or secret key, whose `w-52` input is the wide control: this is their only fix.
|
|
//
|
|
// jsdom does no layout, so what is asserted is the class contract those rules rest on, the same
|
|
// trade SearchBar.dom.test.tsx already makes for the narrow-header rules.
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import type { ConfigItem } from "../../lib/api";
|
|
|
|
vi.mock("react-i18next", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
|
return {
|
|
...actual,
|
|
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
|
};
|
|
});
|
|
|
|
const text = (key: string): ConfigItem => ({
|
|
key,
|
|
type: "str",
|
|
group: "g",
|
|
secret: false,
|
|
min: null,
|
|
max: null,
|
|
is_set: false,
|
|
allow_empty: true,
|
|
choices: [],
|
|
value: "",
|
|
default: "",
|
|
});
|
|
|
|
vi.mock("../../lib/api", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../../lib/api")>();
|
|
return {
|
|
...actual,
|
|
api: {
|
|
...actual.api,
|
|
adminConfig: () =>
|
|
Promise.resolve({
|
|
groups: {
|
|
g: [text("site_name"), { ...text("signups_open"), type: "bool", value: "false" }],
|
|
},
|
|
sections: [{ id: "s", groups: ["g"] }],
|
|
secrets_manageable: true,
|
|
}),
|
|
},
|
|
};
|
|
});
|
|
vi.mock("../../lib/useMe", () => ({ useMe: () => ({ email_configured: true }) }));
|
|
|
|
import ConfigPanel from "./ConfigPanel";
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("ConfigPanel field row", () => {
|
|
it("stacks the label above the control below `sm`, and lets the control fit the screen", async () => {
|
|
render(
|
|
<QueryClientProvider client={new QueryClient()}>
|
|
<ConfigPanel />
|
|
</QueryClientProvider>,
|
|
);
|
|
const label = await screen.findByText("config.fields.site_name.label");
|
|
const row = label.parentElement!.parentElement!; // label → the label/hint column → the row
|
|
// One column on a phone, the two-column row only from `sm` up:
|
|
expect(row.className).toContain("flex-col");
|
|
expect(row.className).toContain("sm:flex-row");
|
|
// The control column may not out-width the screen, and only stops shrinking from `sm` up:
|
|
const control = row.lastElementChild as HTMLElement;
|
|
expect(control.className).toContain("max-w-full");
|
|
expect(control.className).toContain("sm:shrink-0");
|
|
expect(control.className).not.toMatch(/(^|\s)shrink-0(\s|$)/);
|
|
});
|
|
|
|
it("toggles a boolean key from its title and its hint, not only from the switch (F12)", async () => {
|
|
render(
|
|
<QueryClientProvider client={new QueryClient()}>
|
|
<ConfigPanel />
|
|
</QueryClientProvider>,
|
|
);
|
|
const sw = await screen.findByRole("switch", { name: "config.fields.signups_open.label" });
|
|
expect(sw.getAttribute("aria-checked")).toBe("false");
|
|
fireEvent.click(screen.getByText("config.fields.signups_open.label"));
|
|
expect(sw.getAttribute("aria-checked")).toBe("true");
|
|
fireEvent.click(screen.getByText("config.fields.signups_open.hint"));
|
|
expect(sw.getAttribute("aria-checked")).toBe("false");
|
|
// The switch itself still toggles exactly once, not once more through its label.
|
|
fireEvent.click(sw);
|
|
expect(sw.getAttribute("aria-checked")).toBe("true");
|
|
});
|
|
});
|