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