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:
@@ -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([]);
|
||||
|
||||
Reference in New Issue
Block a user