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([]);
+63 -77
View File
@@ -46,6 +46,10 @@ import { PageToolbar } from "./ui/PageShell";
import { useMe } from "../lib/useMe";
import { notifyActionError, notifyKnownFailure } from "../lib/actionErrors";
import { Pressable } from "./ui/Pressable";
import { useContextMenu } from "./ui/useContextMenu";
import { ContextMenu } from "./ui/ContextMenu";
import { MoreButton } from "./ui/MoreButton";
import type { ActionMenuItem } from "./ui/ActionMenu";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
@@ -206,10 +210,58 @@ function UsersRoles({ me }: { me: Me }) {
if (ok) del.mutate({ id: u.id, email: u.email });
};
// The row's actions are ONE menu (DESIGN § Action placement, E31): the role, the suspension and
// the delete — rare and two of them destructive — open from the row's "…", a right-click on the row,
// a long-press or Shift+F10. The row keeps what the admin reads: the name, the role and state
// badges, the last sign-in. A locked action (the demo, yourself) stays listed with its reason.
const rowMenu = useContextMenu<AdminUserRow>({ keyOf: (u) => u.id });
const menuItems = (u: AdminUserRow): ActionMenuItem[] => {
const self = u.id === me.id;
const pending = pendingIds.has(u.id);
const locked = (demoNote: string, selfNote: string) =>
u.is_demo ? demoNote : self ? selfNote : undefined;
const roleNote = locked(t("users.roles.demoLocked"), t("users.roles.selfLocked"));
const suspendNote = locked(t("users.suspend.demoLocked"), t("users.suspend.selfLocked"));
const deleteNote = u.is_demo || self ? t("users.delete.locked") : undefined;
return [
{
id: "role",
label: u.role === "admin" ? t("users.roles.makeUser") : t("users.roles.makeAdmin"),
icon: <Shield />,
disabled: pending || !!roleNote,
note: roleNote,
onSelect: () => onToggle(u),
},
{
id: "suspend",
label: u.is_suspended ? t("users.suspend.undoAction") : t("users.suspend.action"),
icon: u.is_suspended ? <RotateCcw /> : <Ban />,
disabled: pending || !!suspendNote,
note: suspendNote,
onSelect: () => onSuspend(u),
},
{
id: "delete",
label: t("users.delete.action"),
icon: <Trash2 />,
danger: true,
separatorBefore: true,
disabled: pending || !!deleteNote,
note: deleteNote,
onSelect: () => onDelete(u),
},
];
};
const rows = q.data ?? [];
return (
<Section card title={t("users.roles.title")}>
<ContextMenu
menu={rowMenu}
ariaLabel={t("users.rowMenu")}
items={rowMenu.target ? menuItems(rowMenu.target) : []}
/>
{panelFor && <AccountPanel user={panelFor} onClose={() => setPanelFor(null)} />}
<p className="text-xs text-muted leading-relaxed mb-3">{t("users.roles.intro")}</p>
{q.isError && !q.data ? (
@@ -232,6 +284,7 @@ function UsersRoles({ me }: { me: Me }) {
// stretched layer. The hover used to be the name button's own and ended at the first badge.
<div
key={u.id}
{...rowMenu.triggerProps(u)}
className={`${SURFACE.card} ${ROW_LAYER} flex items-center gap-2.5 p-2.5`}
>
<Pressable
@@ -256,83 +309,9 @@ function UsersRoles({ me }: { me: Me }) {
{u.is_active && !u.email_verified && !u.has_google && (
<Badge tone="warn">{t("users.roles.unverified")}</Badge>
)}
<Tooltip
always={u.is_demo || self}
hint={
u.is_demo
? t("users.roles.demoLocked")
: self
? t("users.roles.selfLocked")
: u.role === "admin"
? t("users.roles.makeUser")
: t("users.roles.makeAdmin")
}
>
<Button
variant="glass"
size="xs"
className="relative shrink-0"
onClick={() => onToggle(u)}
// Locked (demo / self) is unavailable WITH a reason, the tooltip's: aria-disabled keeps it
// focusable so a keyboard user reaches the reason. A pending write is transient.
aria-disabled={u.is_demo || self}
disabled={pendingIds.has(u.id)}
icon={<Shield className="w-3.5 h-3.5" />}
>
{u.role === "admin" ? t("users.roles.makeUser") : t("users.roles.makeAdmin")}
</Button>
</Tooltip>
<Tooltip
always={u.is_demo || self}
hint={
u.is_demo
? t("users.suspend.demoLocked")
: self
? t("users.suspend.selfLocked")
: u.is_suspended
? t("users.suspend.undoAction")
: t("users.suspend.action")
}
>
<IconButton
noTitle
label={
u.is_suspended ? t("users.suspend.undoAction") : t("users.suspend.action")
}
size="sm"
variant={u.is_suspended ? "outline-warn" : "outline"}
className="relative"
onClick={() => onSuspend(u)}
aria-disabled={u.is_demo || self}
disabled={pendingIds.has(u.id)}
>
{u.is_suspended ? (
<RotateCcw className="w-4 h-4" />
) : (
<Ban className="w-4 h-4" />
)}
</IconButton>
</Tooltip>
<Tooltip
always={u.is_demo || self}
hint={u.is_demo || self ? t("users.delete.locked") : t("users.delete.action")}
>
<IconButton
noTitle
label={t("users.delete.action")}
size="sm"
variant="outline-danger"
className="relative"
onClick={() => onDelete(u)}
aria-disabled={u.is_demo || self}
disabled={pendingIds.has(u.id)}
>
<Trash2 className="w-4 h-4" />
</IconButton>
</Tooltip>
{/* How long ago this account last signed in. LAST in the row on purpose: the badges and
the role button before it vary in width row to row, so only the row's own right
edge lines the values up into one column. The exact moment is the hint here and a
{/* How long ago this account last signed in. At the row's right end on purpose (only the
fixed-width "…" after it): the badges before it vary in width row to row, so only
the right edge lines the values up into one column. The exact moment is the hint here and a
line in the account panel; below `sm` the row has no room, and the panel still says it. */}
<Tooltip
hint={
@@ -347,6 +326,13 @@ function UsersRoles({ me }: { me: Me }) {
{u.last_login_at ? relativeTime(u.last_login_at, lang) : "—"}
</span>
</Tooltip>
<MoreButton
label={t("users.rowMenu")}
size="md"
expanded={rowMenu.open && rowMenu.target?.id === u.id}
menuProps={rowMenu.buttonProps(u)}
className="relative"
/>
</div>
);
})}
@@ -0,0 +1,48 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import { ChevronRight } from "lucide-react";
import type { AuditRow } from "../lib/api";
import { Button } from "./ui/Button";
import { RelativeTime } from "./ui/RelativeTime";
/** A folded run of one job's successful runs (lib/auditGroups, E31): the row shows the newest, this
* opens the earlier ones, each with its moment and its result (`detailOf`, the row's own wording). */
export default function AuditEarlierRuns({
runs,
detailOf,
}: {
runs: AuditRow[];
detailOf: (run: AuditRow) => string | null;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const earlier = runs.slice(1);
return (
<div className="mt-1">
<Button
variant="link-muted"
aria-expanded={open}
onClick={() => setOpen(!open)}
icon={<ChevronRight className={clsx("transition-transform", open && "rotate-90")} />}
>
{t("audit.earlierRuns", { count: earlier.length })}
</Button>
{open && (
<ul className="mt-1 space-y-0.5 text-2xs text-muted">
{earlier.map((run) => (
<li key={run.id} className="flex gap-2">
<RelativeTime
at={run.created_at}
seconds
empty="—"
className="shrink-0 tabular-nums"
/>
<span className="min-w-0 wrap-break-word">{detailOf(run)}</span>
</li>
))}
</ul>
)}
</div>
);
}
+3 -1
View File
@@ -14,6 +14,7 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
import { auditColumns } from "./auditColumns";
import { notifyActionError } from "../lib/actionErrors";
import { compareText } from "../lib/collate";
import { foldJobRuns } from "../lib/auditGroups";
// Admin-only audit trail: an append-only who/what/when log of admin actions + scheduler run
// summaries (never per-row). Read-only apart from Clear. Client-side sort/filter/paginate via
@@ -25,7 +26,8 @@ export default function AuditLog() {
const confirm = useConfirm();
const q = useQuery({ queryKey: qk.adminAudit(), queryFn: () => api.adminAudit() });
const data = q.data;
const rows = useMemo(() => data?.items ?? [], [data]);
// Back-to-back successful runs of one job fold into one row (lib/auditGroups, E31).
const rows = useMemo(() => foldJobRuns(data?.items ?? []), [data]);
const actionOptions = useMemo(() => {
// By what the reader sees — the named action, in their alphabet — not by the code.
+16 -2
View File
@@ -29,6 +29,9 @@ import ChannelAboutContent from "./ChannelAbout";
import { PageToolbar } from "./ui/PageShell";
import { PageToolbarSlot } from "./ui/pageToolbarSlot";
import { useFeedFilters } from "./feedFiltersContext";
import { SHOW_IDS } from "./feedFiltersInit";
import { usePref } from "../lib/usePref";
import { LS } from "../lib/storage";
import { useConfirm } from "./confirmContext";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
@@ -260,7 +263,13 @@ export default function ChannelPage({
// Channel-scoped feed: the whole catalog filtered to this channel (scope=all + source=all so
// the explored, per-user-private videos show — the backend gates them to this explorer).
const [filters, setFilters] = useState<FeedFilters>(() => ({
// The watch state is the one filter a channel page remembers (the owner, E31): a fresh page shows
// what is still unwatched, and a change sticks — across channels, reloads and devices.
const [show, setShow] = usePref<string>("channelShow", "unwatched", {
localKey: LS.channelShow,
parse: (v) => (typeof v === "string" && SHOW_IDS.includes(v) ? v : "unwatched"),
});
const [ownFilters, setOwnFilters] = useState<FeedFilters>(() => ({
tags: [],
tagModes: DEFAULT_TAG_MODES,
q: "",
@@ -271,9 +280,14 @@ export default function ChannelPage({
includeNormal: globalFilters.includeNormal,
includeShorts: globalFilters.includeShorts,
includeLive: globalFilters.includeLive,
show: "all",
show: "unwatched",
channelIds: [channelId],
}));
const filters: FeedFilters = { ...ownFilters, show };
const setFilters = (next: FeedFilters) => {
if (next.show !== show) setShow(next.show);
setOwnFilters(next);
};
const name = ch?.title ?? initialName ?? channelId;
const ytUrl = channelYouTubeUrl(channelId, ch?.handle);
@@ -0,0 +1,109 @@
// @vitest-environment jsdom
//
// E31 (the owner): a freshly opened channel page shows what is still unwatched, and a watch state the
// viewer picks there sticks — a reload (a fresh mount, seeded from the stored preference) keeps it.
import type { ReactNode } from "react";
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, cleanup, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { FeedFilters } from "../lib/api";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
vi.mock("./navigationContext", () => ({
useNavigationActions: () => ({ closeChannel: vi.fn(), openChannel: vi.fn() }),
}));
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true }));
vi.mock("./feedFiltersContext", () => ({
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
}));
const feed = vi.hoisted(() => ({
last: null as null | { filters: FeedFilters; setFilters: (f: FeedFilters) => void },
}));
vi.mock("./Feed", () => ({
default: (p: { filters: FeedFilters; setFilters: (f: FeedFilters) => void }) => {
feed.last = p;
return null;
},
}));
vi.mock("./ChannelAbout", () => ({ default: () => null }));
vi.mock("./ui/PageShell", async (importOriginal) => ({
...(await importOriginal<typeof import("./ui/PageShell")>()),
PageToolbar: ({ children }: { children?: ReactNode }) => <>{children}</>,
}));
import ChannelPage from "./ChannelPage";
import { api, type ChannelDetail, type Me } from "../lib/api";
import { qk } from "../lib/queryKeys";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
feed.last = null;
});
const CH = "UCqoAEDirJPjEUFcF2FklnBA";
function mount(preferences: Record<string, unknown> = {}) {
vi.spyOn(api, "channelDetail").mockResolvedValue({
id: CH,
title: "StarTalk",
subscribed: true,
stored_videos: 10,
} as unknown as ChannelDetail);
vi.spyOn(api, "tags").mockResolvedValue([] as never);
const save = vi.spyOn(api, "savePrefs").mockResolvedValue({} as never);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), { id: 1, preferences } as unknown as Me);
render(
<QueryClientProvider client={qc}>
<ChannelPage
channelId={CH}
initialName="StarTalk"
me={{ id: 1, can_write: true, can_read: true } as unknown as Me}
view="cards"
setView={() => {}}
onShowInFeed={() => {}}
/>
</QueryClientProvider>,
);
return save;
}
describe("ChannelPage — the watch state", () => {
it("shows only the unwatched videos on a freshly opened page", () => {
mount();
expect(feed.last?.filters.show).toBe("unwatched");
});
it("stores a picked watch state", () => {
const save = mount();
act(() => feed.last!.setFilters({ ...feed.last!.filters, show: "all" }));
expect(feed.last?.filters.show).toBe("all");
expect(save).toHaveBeenCalledWith({ channelShow: "all" });
});
it("keeps the stored watch state after a reload", () => {
mount({ channelShow: "watched" });
expect(feed.last?.filters.show).toBe("watched");
});
it("falls back to unwatched for a stored value it does not know", () => {
mount({ channelShow: "bogus" });
expect(feed.last?.filters.show).toBe("unwatched");
});
});
+1 -1
View File
@@ -37,6 +37,7 @@ import {
} from "../lib/feedSort";
import { type SortOption, type SortState } from "../lib/columnSort";
import { usePref } from "../lib/usePref";
import { SHOW_IDS } from "./feedFiltersInit";
import { FEED_VIEWS, type FeedView } from "../lib/feedView";
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
import ActiveFilterChips from "./ActiveFilterChips";
@@ -64,7 +65,6 @@ const PAGE = 60;
// The "Show" view filter, content-type filter and ordering live in the feed toolbar
// (above the cards), not the filter sidebar.
const SHOW_IDS = ["unwatched", "in_progress", "all", "watched", "hidden"];
const CONTENT = [
{ key: "includeNormal", label: "sidebar.content.normal" },
{ key: "includeShorts", label: "sidebar.content.shorts" },
+3
View File
@@ -35,6 +35,7 @@ import {
RAIL_ROW,
} from "./ui/NavRail";
import { usePrefs } from "./prefsContext";
import { pageHref } from "../lib/nav";
// Primary app navigation: a thin icon-only left rail. The modules used to hide under the avatar
// dropdown; they now live here, each revealing its name in a hover/focus flyout label. Account
@@ -185,6 +186,7 @@ export default function NavSidebar({
showLabel={railLabels}
active={page === p}
badge={badge}
href={pageHref(p)}
onClick={() => setPage(p)}
/>
);
@@ -214,6 +216,7 @@ export default function NavSidebar({
shortLabel={t(moduleRailKey.settings)}
showLabel={railLabels}
active={page === "settings"}
href={pageHref("settings")}
onClick={() => setPage("settings")}
/>
@@ -4,7 +4,7 @@
// "log_level = DEBUG", "Background sync paused") reads in the reader's language, rebuilt from the row's
// data through the REAL catalog. A row whose summary is data (an e-mail, a URL) keeps it.
import { afterEach, describe, expect, it } from "vitest";
import { render } from "@testing-library/react";
import { fireEvent, render } from "@testing-library/react";
import i18n, { setLanguage } from "../i18n";
import type { AuditRow } from "../lib/api";
import { auditColumns } from "./auditColumns";
@@ -152,3 +152,49 @@ describe("an audit row's details", () => {
);
});
});
// E31: a folded run (lib/auditGroups) shows its newest run and opens to the earlier ones; a failed run
// is the line to see in a page of routine ones.
describe("a folded run of one job", () => {
const ok = (id: number, enriched: number) =>
row({
id,
target_type: "job",
target_id: "enrich",
after: { status: "ok", result: { enriched } },
});
it("names how many earlier runs it holds, and opens to each", () => {
setLanguage("hu");
const t = i18n.getFixedT("hu");
const col = auditColumns(t, "hu", []).find((c) => c.key === "details")!;
const runs = [ok(3, 50), ok(2, 40), ok(1, 30)];
const { getByRole, container } = render(<>{col.render!({ ...runs[0]!, runs })}</>);
const toggle = getByRole("button", { name: "2 korábbi futás" });
expect(toggle.getAttribute("aria-expanded")).toBe("false");
expect(container.querySelector("li")).toBeNull();
fireEvent.click(toggle);
expect(toggle.getAttribute("aria-expanded")).toBe("true");
expect([...container.querySelectorAll("li")].map((li) => li.textContent)).toEqual([
"—dúsítva: 40",
"—dúsítva: 30",
]);
});
it("has no toggle for a lone run", () => {
const t = i18n.getFixedT("hu");
const col = auditColumns(t, "hu", []).find((c) => c.key === "details")!;
const { container } = render(<>{col.render!(ok(1, 5))}</>);
expect(container.querySelector("button")).toBeNull();
});
it("paints a failed run's line in the danger colour", () => {
const t = i18n.getFixedT("hu");
const col = auditColumns(t, "hu", []).find((c) => c.key === "details")!;
const failed = row({ target_type: "job", target_id: "enrich", after: { status: "error" } });
const { container } = render(<>{col.render!(failed)}</>);
expect(container.querySelector(".wrap-break-word")!.className).toContain("text-danger");
const { container: fine } = render(<>{col.render!(ok(2, 5))}</>);
expect(fine.querySelector(".wrap-break-word")!.className).not.toContain("text-danger");
});
});
+15 -2
View File
@@ -1,5 +1,8 @@
import type { TFunction } from "i18next";
import clsx from "clsx";
import type { AuditRow } from "../lib/api";
import type { AuditListRow } from "../lib/auditGroups";
import AuditEarlierRuns from "./AuditEarlierRuns";
import { formatNumber } from "../lib/format";
import type { JobResult } from "../lib/api";
import { jobResultText, jobRunText } from "../lib/jobResult";
@@ -105,7 +108,7 @@ export function auditColumns(
t: TFunction,
lang: string,
actionOptions: { value: string; label: string }[],
): Column<AuditRow>[] {
): Column<AuditListRow>[] {
return [
{
key: "when",
@@ -147,13 +150,23 @@ export function auditColumns(
const local = localDetails(t, r, lang);
const summary = local ? local.head : r.summary;
const change = local ? local.sub : changeText(r);
// A failed run is the row to see in a page of routine ones (E31): its line in the danger colour.
const failed = r.action === "job_run" && r.after?.status === "error";
return (
// wrap-break-word (wrap), NOT truncate: in the auto-layout table a `truncate` (nowrap) cell
// sizes to its longest row, so one long summary/change stretched the whole table past the
// card (the row rules bled into the page). Wrapping keeps every row inside the container.
<div className="min-w-0">
{summary && <div className="wrap-break-word">{summary}</div>}
{summary && (
<div className={clsx("wrap-break-word", failed && "text-danger")}>{summary}</div>
)}
{change && <div className="text-2xs text-muted wrap-break-word">{change}</div>}
{r.runs && r.runs.length > 1 && (
<AuditEarlierRuns
runs={r.runs}
detailOf={(run) => localDetails(t, run, lang)?.sub ?? run.summary}
/>
)}
</div>
);
},
@@ -18,6 +18,9 @@ export const DEFAULT_FILTERS: FeedFilters = {
show: "unwatched",
};
/** The watch-state choices, in the order the filter offers them. */
export const SHOW_IDS = ["unwatched", "in_progress", "all", "watched", "hidden"];
export const FILTERS_KEY = LS.filters;
// This account's own persisted filters (per-account keys — never another account's). No account
@@ -6,7 +6,7 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { Bell } from "lucide-react";
import { NavRailAction, NavRailItem } from "./NavRail";
@@ -24,10 +24,11 @@ describe("NavRailItem", () => {
showLabel={false}
active
badge={3}
href="/?page=notifications"
onClick={() => {}}
/>,
);
const row = screen.getByRole("button", { name: "Notifications (3)" });
const row = screen.getByRole("link", { name: "Notifications (3)" });
expect(row.getAttribute("aria-current")).toBe("page");
expect(row.className).toContain("bg-accent/15");
expect(row.textContent).not.toContain("Alerts");
@@ -40,10 +41,11 @@ describe("NavRailItem", () => {
label="Notifications"
shortLabel="Alerts"
showLabel
href="/?page=notifications"
onClick={() => {}}
/>,
);
const row = screen.getByRole("button", { name: "Alerts" });
const row = screen.getByRole("link", { name: "Alerts" });
expect(row.getAttribute("aria-current")).toBeNull();
});
@@ -57,10 +59,51 @@ describe("NavRailItem", () => {
shortLabel="Alerts"
showLabel
badge={20}
href="/?page=notifications"
onClick={() => {}}
/>,
);
expect(screen.getByRole("button", { name: "Alerts (20)" })).toBeTruthy();
expect(screen.getByRole("link", { name: "Alerts (20)" })).toBeTruthy();
});
// E31 (the owner): a middle-click on a rail row opens the page in a new tab. The row is a real link
// to the page, so the browser's own gestures work; only a plain left click navigates in-app.
it("is a link to its page, navigating in-app only on a plain left click", () => {
const onClick = vi.fn();
render(
<NavRailItem
icon={Bell}
label="Notifications"
shortLabel="Alerts"
showLabel={false}
href="/?page=notifications"
onClick={onClick}
/>,
);
const row = screen.getByRole("link", { name: "Notifications" });
expect(row.getAttribute("href")).toBe("/?page=notifications");
// Whether the row left the click to the browser, read after the app's own handler ran; then the
// click is stopped here, as jsdom cannot follow a link.
const leftToBrowser: boolean[] = [];
const record = (e: Event) => {
leftToBrowser.push(!e.defaultPrevented);
e.preventDefault();
};
window.addEventListener("click", record);
try {
fireEvent.click(row, { button: 0 });
expect(onClick).toHaveBeenCalledTimes(1);
for (const init of [
{ button: 1 },
{ button: 0, ctrlKey: true },
{ button: 0, metaKey: true },
])
fireEvent.click(row, init);
} finally {
window.removeEventListener("click", record);
}
expect(leftToBrowser).toEqual([false, true, true, true]);
expect(onClick).toHaveBeenCalledTimes(1);
});
});
+14 -3
View File
@@ -4,6 +4,7 @@ import type { LucideIcon } from "lucide-react";
import { Pressable } from "./Pressable";
import { SURFACE } from "./surfaces";
import Tooltip from "./Tooltip";
import { isPlainLeftClick } from "../../lib/nav";
// B7 (docs/DESIGN.md): the app's primary navigation rail. Slim — 3.75rem wide whether or not the
// labels are on (they add height, never width) — with "you are here" as a soft accent tint plus a 3px
@@ -76,6 +77,7 @@ export function NavRailItem({
showLabel,
active = false,
badge,
href,
onClick,
testId,
}: {
@@ -88,15 +90,24 @@ export function NavRailItem({
active?: boolean;
/** An unread / in-progress count on the icon's corner (0 hides it). */
badge?: number;
/** The page's own address. A page row is a real link (the owner, E31): a middle-click, a
* Ctrl/Cmd-click or the browser's "Open in new tab" opens the page in a tab of its own. */
href: string;
/** The in-app navigation, run only for a plain left click. */
onClick: () => void;
testId?: string;
}) {
const shown = !!badge && badge > 0;
return (
<Tooltip hint={label} side="right" always openDelay={350}>
<Pressable
<a
data-testid={testId}
onClick={onClick}
href={href}
onClick={(e) => {
if (!isPlainLeftClick(e)) return; // the browser opens its tab or window
e.preventDefault();
onClick();
}}
// With the label visible the row's name starts with that text (WCAG 2.5.3: the spoken name
// contains the visible one); the full name stays in the tooltip. Icon-only, the full name is the
// name. The exact count rides in the name either way, so "Messages (3)" is heard and not only
@@ -131,7 +142,7 @@ export function NavRailItem({
{shortLabel}
</span>
)}
</Pressable>
</a>
</Tooltip>
);
}
@@ -46,7 +46,7 @@ const PLAIN: Record<string, [number, string]> = {
"ui/ActionMenu.tsx": [1, "a menu row"],
"ui/BackToTop.tsx": [1, "a control"],
"ui/CommandPalette.tsx": [1, "a command row"],
"ui/NavRail.tsx": [2, "nav items"],
"ui/NavRail.tsx": [1, "the action rows (About, Sign out); a page row is a link (E31)"],
"ui/PanelGroup.tsx": [2, "a group's collapse toggle and its label"],
"ui/SidePanel.tsx": [2, "panel controls"],
"ui/tableHeader.tsx": [1, "a column's sort header"],
+3 -1
View File
@@ -19,5 +19,7 @@
"actor": "Who",
"action": "Action",
"details": "Details"
}
},
"earlierRuns_one": "{{count}} earlier run",
"earlierRuns_other": "{{count}} earlier runs"
}
+2 -1
View File
@@ -101,5 +101,6 @@
"setPassword": "Create a set-password link",
"setPasswordAgain": "Create another link",
"linkFailed": "Couldn't create the link. Please try again."
}
},
"rowMenu": "User actions"
}
+3 -1
View File
@@ -19,5 +19,7 @@
"actor": "Ki",
"action": "Művelet",
"details": "Részletek"
}
},
"earlierRuns_one": "{{count}} korábbi futás",
"earlierRuns_other": "{{count}} korábbi futás"
}
+2 -1
View File
@@ -101,5 +101,6 @@
"setPassword": "Jelszóbeállító link készítése",
"setPasswordAgain": "Új link készítése",
"linkFailed": "Nem sikerült létrehozni a linket. Próbáld újra."
}
},
"rowMenu": "Felhasználó műveletei"
}
+60
View File
@@ -0,0 +1,60 @@
// E31: the audit log's first screen was one job's "done" ten times over. Back-to-back successful runs
// of one job fold into one row; a failure, a skip, another job or an admin's own action breaks the run.
import { describe, expect, it } from "vitest";
import type { AuditRow } from "./api";
import { foldJobRuns } from "./auditGroups";
let nextId = 1;
const run = (job: string, status = "ok"): AuditRow => ({
id: nextId++,
created_at: null,
actor: null,
action: "job_run",
target_type: "job",
target_id: job,
summary: null,
before: null,
after: { status },
});
const admin = (): AuditRow => ({ ...run("x"), action: "config_set", target_type: "config" });
const shape = (rows: ReturnType<typeof foldJobRuns>) =>
rows.map((r) => `${r.target_id}:${String(r.after?.status)}×${r.runs?.length ?? 1}`);
describe("folding the job runs", () => {
it("folds back-to-back successful runs of one job, newest first", () => {
const rows = [run("enrich"), run("enrich"), run("enrich"), run("people")];
const out = foldJobRuns(rows);
expect(shape(out)).toEqual(["enrich:ok×3", "people:ok×1"]);
expect(out[0]!.id).toBe(rows[0]!.id);
expect(out[0]!.runs!.map((r) => r.id)).toEqual(rows.slice(0, 3).map((r) => r.id));
});
it("never folds a failed or skipped run, and lets it break the run", () => {
const rows = [run("rss"), run("rss", "error"), run("rss", "error"), run("rss"), run("rss")];
expect(shape(foldJobRuns(rows))).toEqual([
"rss:ok×1",
"rss:error×1",
"rss:error×1",
"rss:ok×2",
]);
expect(shape(foldJobRuns([run("rss", "skipped"), run("rss", "skipped")]))).toEqual([
"rss:skipped×1",
"rss:skipped×1",
]);
});
it("keeps an admin's own action between two runs, and the runs apart", () => {
expect(shape(foldJobRuns([run("enrich"), admin(), run("enrich")]))).toEqual([
"enrich:ok×1",
"x:ok×1",
"enrich:ok×1",
]);
});
it("leaves the rows it was given as they were", () => {
const rows = [run("enrich"), run("enrich")];
foldJobRuns(rows);
expect("runs" in rows[0]!).toBe(false);
});
});
+32
View File
@@ -0,0 +1,32 @@
import type { AuditRow } from "./api";
// The audit log's scheduler noise, folded (E31, the owner's pick): the background jobs write a row per
// run that changed something, so an hour of "enrichment done" filled the first screen and pushed the
// admins' own actions down. Back-to-back SUCCESSFUL runs of the same job become one row that names how
// many there were and opens to each; a failed or skipped run is never folded — it stays a row of its
// own, where it is seen. Rows arrive newest first; the group stands at its newest run's place.
/** An audit row as the table lists it: a lone row, or the newest of a run of folded ones (`runs`, the
* whole run newest first, the row itself included). */
export type AuditListRow = AuditRow & { runs?: AuditRow[] };
/** The job a row is a successful run of, or null for any other row. */
function okRunOf(row: AuditRow): string | null {
if (row.action !== "job_run" || row.target_type !== "job" || !row.target_id) return null;
return row.after?.status === "ok" ? row.target_id : null;
}
export function foldJobRuns(rows: readonly AuditRow[]): AuditListRow[] {
const out: AuditListRow[] = [];
let head: AuditListRow | null = null;
for (const row of rows) {
const job = okRunOf(row);
if (job !== null && head !== null && okRunOf(head) === job) {
head.runs = [...(head.runs ?? [head]), row];
continue;
}
head = { ...row };
out.push(head);
}
return out;
}
+7
View File
@@ -17,6 +17,13 @@ export function navigateTo(p: Page): void {
_navigate?.(p);
}
/** A page's own address, for a link to it (the nav rail's rows). Only the page: the rest of a page's
* state — the feed's filters, Plex's library — a fresh tab restores from the account's own storage,
* as a bookmark would; the CURRENT page's params would be the wrong page's state. */
export function pageHref(p: Page): string {
return `/?page=${p}`;
}
/** Should this click be handled IN-APP, or left to the browser?
*
* An anchor that ALSO navigates in-app has to keep the browser's own gestures working — Ctrl/Cmd-click
+1
View File
@@ -35,6 +35,7 @@ export const LS = {
channelLayout: "siftlode.channelLayout",
// The manager's sort, lifted out of the table so the card layout shares it (per-account, per-tab).
channelSort: "siftlode.channelSort",
channelShow: "siftlode.channelShow",
channelDiscoverySort: "siftlode.channelDiscoverySort",
// Cards-per-page for the card layout (the table keeps its own rows-per-page in its persist blob).
channelCardSize: "siftlode.channelCardSize",