feat(ui): E31 S4a — rail rows are links, the channel page opens on unwatched, users and audit tidied

- Nav rail page rows are real links (/?page=<id>): a middle-click or
  Ctrl-click opens the page in a new tab; a plain click stays in-app.
- A fresh channel page shows the unwatched videos; a picked watch state
  is a remembered preference (channelShow).
- Users: the role, suspend and delete move into the row's one menu
  (the "…", right-click, Shift+F10); locked ones keep their reason.
- Audit log: back-to-back successful runs of one job fold into one row
  that opens to the earlier ones; a failed run reads in the danger colour.
- DESIGN.md decision log: the E31 picks.
This commit is contained in:
2026-10-01 22:51:38 +02:00
parent ab146211fa
commit 87d71e3762
23 changed files with 542 additions and 98 deletions
@@ -116,13 +116,63 @@ describe("a user row", () => {
});
});
/** The open menu's rows (a closed popover's are hidden from the accessibility tree in jsdom). */
const menuRows = () => screen.queryAllByRole("menuitem", { hidden: true });
/** The row's actions live in its "…" menu (E31): open it, then pick the row. */
async function chooseInRowMenu(label: string) {
fireEvent.click(await screen.findByRole("button", { name: "users.rowMenu" }));
const item = await waitFor(() => {
const found = menuRows().find((m) => m.textContent?.startsWith(label));
if (!found) throw new Error(`no menu row "${label}"`);
return found;
});
fireEvent.click(item);
}
// E31 (DESIGN § Action placement): the role, the suspension and the delete moved off the row into
// ONE menu — the row's "…", which a right-click on the row opens too. A locked one stays listed with
// its reason.
describe("a user row's menu", () => {
it("holds the role, the suspension and the delete; the row shows no action button", async () => {
mount();
const open = await screen.findByTitle("users.account.open");
const card = open.parentElement!;
const buttons = [...card.querySelectorAll("button")].filter((b) => b !== open);
expect(buttons.map((b) => b.getAttribute("aria-label"))).toEqual(["users.rowMenu"]);
fireEvent.contextMenu(card, { clientX: 5, clientY: 5 });
await waitFor(() => expect(menuRows().length).toBe(3));
expect(menuRows().map((i) => i.textContent)).toEqual([
"users.roles.makeAdmin",
"users.suspend.action",
"users.delete.action",
]);
});
it("lists your own row's actions as locked, with the reason", async () => {
vi.spyOn(api, "adminUsers").mockResolvedValue([{ ...row, id: 1, email: "admin@x" }] as never);
render(
<QueryClientProvider client={new QueryClient()}>
<AdminUsers />
</QueryClientProvider>,
);
fireEvent.click(await screen.findByRole("button", { name: "users.rowMenu" }));
await waitFor(() => expect(menuRows().length).toBe(3));
const [role, suspend, del] = menuRows();
expect(role!.hasAttribute("disabled")).toBe(true);
expect(role!.textContent).toBe("users.roles.makeAdminusers.roles.selfLocked");
expect(suspend!.textContent).toBe("users.suspend.actionusers.suspend.selfLocked");
expect(del!.textContent).toBe("users.delete.actionusers.delete.locked");
});
});
describe("a user action that fails", () => {
it("says a user who is gone, and refetches the list", async () => {
const users = mount();
vi.spyOn(globalThis, "fetch").mockResolvedValue(
answer(404, { code: "not_found", reason: "user" }),
);
fireEvent.click(await screen.findByText("users.roles.makeAdmin"));
await chooseInRowMenu("users.roles.makeAdmin");
await waitFor(() =>
expect(notify).toHaveBeenCalledWith({
level: "error",
@@ -136,7 +186,7 @@ describe("a user action that fails", () => {
it("is not toasted on top of the error dialog the request already opened", async () => {
mount();
vi.spyOn(globalThis, "fetch").mockResolvedValue(answer(409, "Cannot demote the last admin"));
fireEvent.click(await screen.findByText("users.roles.makeAdmin"));
await chooseInRowMenu("users.roles.makeAdmin");
const { reportError } = await import("../lib/errorDialog");
await waitFor(() => expect(reportError).toHaveBeenCalled());
expect(notify.mock.calls.filter(([n]) => n.level === "error")).toEqual([]);