feat(design): the channel menu's Tags and Priority submenus (C1, E28 S3e3b)

Tags A-Z as in-place checks (the menu stays open for the next); Priority
as the fixed steps 10 5 3 2 1 0, an off-scale current value ticked in its
place. A pick re-sorts the list at once, moving only that row
(placeByPriority, the server's order), and the table / card pager and
the focus follow it (DataTable and useCardPager take a reveal seq,
listNav.focusNavItem lands on the row's own "…", a virtualized card
revealed first). ActionMenu rows take check: many | one. The menu reads
the row fresh from the cache and keys its target by id.
This commit is contained in:
2026-09-26 17:34:47 +02:00
parent 2b3094ffc5
commit 5e5c8a42a9
15 changed files with 467 additions and 28 deletions
@@ -2,7 +2,8 @@
//
// A subscribed channel's ONE action list (E28 C1): what the manager row's "…" (and its right-click,
// long-press and Shift+F10) offers — and the row buttons it replaced, now rows here: full history,
// hide (an undo toast instead of a confirm), unsubscribe (danger, confirmed).
// hide (an undo toast instead of a confirm), unsubscribe (danger, confirmed) — and the Tags and
// Priority submenus (E28 S3e3b).
import { describe, it, expect, vi, afterEach } from "vitest";
import { renderHook, act, cleanup, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -27,7 +28,8 @@ vi.mock("./confirmContext", () => ({ useConfirm: () => h.confirm }));
vi.mock("../lib/useMe", () => ({ useMe: () => ({ role: h.role, can_write: h.canWrite }) }));
import { useChannelActions } from "./useChannelActions";
import { api, type ManagedChannel } from "../lib/api";
import { api, type ManagedChannel, type Tag } from "../lib/api";
import type { ActionMenuItem } from "./ui/ActionMenu";
afterEach(() => {
cleanup();
@@ -37,6 +39,9 @@ afterEach(() => {
h.confirm.mockImplementation(async () => true);
h.role = "user";
h.canWrite = true;
tags = [];
onToggleTag.mockReset();
onPickPriority.mockReset();
});
const ch = (over: Partial<ManagedChannel> = {}): ManagedChannel => ({
@@ -62,12 +67,19 @@ const ch = (over: Partial<ManagedChannel> = {}): ManagedChannel => ({
});
const onView = vi.fn();
const onToggleTag = vi.fn();
const onPickPriority = vi.fn();
let tags: Tag[] = [];
const tag = (id: number, name: string) => ({ id, name }) as Tag;
function mount() {
const qc = new QueryClient({ defaultOptions: { mutations: { retry: false } } });
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
return renderHook(() => useChannelActions({ onView, onOpenWizard: () => {} }), { wrapper });
return renderHook(
() => useChannelActions({ onView, onOpenWizard: () => {}, tags, onToggleTag, onPickPriority }),
{ wrapper },
);
}
const pin = [{ id: "pin", label: "pin", onSelect: () => {} }];
const ids = (items: { id: string }[]) => items.map((i) => i.id);
@@ -75,11 +87,13 @@ const rowsFor = (c: ManagedChannel) => mount().result.current.items(c, { pin });
const row = (c: ManagedChannel, id: string) => rowsFor(c).find((i) => i.id === id)!;
describe("useChannelActions — the menu", () => {
it("lists open, pin, the YouTube + copy group, full history, hide and unsubscribe", () => {
it("lists open, pin, tags + priority, the YouTube + copy group, full history, hide and unsubscribe", () => {
const items = rowsFor(ch());
expect(ids(items)).toEqual([
"open",
"pin",
"tags",
"priority",
"open-youtube",
"copy-text",
"copy-link",
@@ -87,8 +101,9 @@ describe("useChannelActions — the menu", () => {
"hide",
"unsubscribe",
]);
// Groups: YouTube/copy, the channel's own settings, then the destructive row alone.
// Groups: tuning, YouTube/copy, the channel's own settings, then the destructive row alone.
expect(items.filter((i) => i.separatorBefore).map((i) => i.id)).toEqual([
"tags",
"open-youtube",
"backfill",
"unsubscribe",
@@ -188,3 +203,45 @@ describe("useChannelActions — hide and unsubscribe", () => {
await waitFor(() => expect(unsub).toHaveBeenCalledWith("UC1"));
});
});
const sub = (it: ActionMenuItem) => (it.items ?? []) as ActionMenuItem[];
describe("useChannelActions — the Tags submenu", () => {
it("lists the reader's tags A–Z, the channel's own ticked, each a toggle that stays open", () => {
tags = [tag(3, "rust"), tag(1, "Go"), tag(2, "music")];
const items = sub(row(ch({ tag_ids: [2] }), "tags"));
expect(items.map((i) => i.label)).toEqual(["Go", "music", "rust"]);
expect(items.map((i) => ("check" in i ? i.check : null))).toEqual([
{ kind: "many", checked: false },
{ kind: "many", checked: true },
{ kind: "many", checked: false },
]);
items[1]!.onSelect!();
expect(onToggleTag).toHaveBeenCalledWith(expect.objectContaining({ id: "UC1" }), 2);
});
it("stays in place, off, saying where tags are made, with no tag yet", () => {
expect(row(ch(), "tags")).toMatchObject({ disabled: true, note: "channels.row.noTagsYet" });
expect(row(ch(), "tags").items).toBeUndefined();
});
});
describe("useChannelActions — the Priority submenu", () => {
it("offers the fixed steps with the current one ticked, and picks a new one", () => {
const items = sub(row(ch({ priority: 3 }), "priority"));
expect(items.map((i) => i.label)).toEqual(["10", "5", "3", "2", "1", "0"]);
expect(items.filter((i) => "check" in i && i.check?.checked).map((i) => i.label)).toEqual([
"3",
]);
expect(items.every((i) => "check" in i && i.check?.kind === "one")).toBe(true);
items[0]!.onSelect!();
expect(onPickPriority).toHaveBeenCalledWith(expect.objectContaining({ id: "UC1" }), 10);
});
it("shows an off-scale priority ticked in its place, and re-picking the current one is a no-op", () => {
const items = sub(row(ch({ priority: 7 }), "priority"));
expect(items.map((i) => i.label)).toEqual(["10", "7", "5", "3", "2", "1", "0"]);
items[1]!.onSelect!();
expect(onPickPriority).not.toHaveBeenCalled();
});
});