feat(channels): an Added column, and About cards that wait for a resting pointer
The channel manager lists when each follow reached the list (the subscription import that first saw it, or a subscribe made here) as a sortable Added date after Last upload. The About cell's card opens after the pointer rests on it for half a second instead of at once, and a scroll puts an open one away.
This commit is contained in:
@@ -82,6 +82,7 @@
|
||||
"stored": "Stored",
|
||||
"subs": "Subscribers",
|
||||
"lastUpload": "Last upload",
|
||||
"added": "Added",
|
||||
"length": "Length",
|
||||
"tags": "Tags",
|
||||
"actions": "Actions"
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
"stored": "Tárolt",
|
||||
"subs": "Feliratkozók",
|
||||
"lastUpload": "Utolsó feltöltés",
|
||||
"added": "Hozzáadva",
|
||||
"length": "Hossz",
|
||||
"tags": "Címkék",
|
||||
"actions": "Műveletek"
|
||||
|
||||
@@ -73,6 +73,9 @@ export interface ManagedChannel {
|
||||
description: string | null;
|
||||
stored_videos: number;
|
||||
last_video_at: string | null;
|
||||
/** When the follow reached this list: the subscription import that first saw it, or a
|
||||
* subscribe made here. */
|
||||
added_at: string;
|
||||
total_duration_seconds: number;
|
||||
priority: number;
|
||||
hidden: boolean;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The About cell's card opened the instant a pointer crossed the cell, so scrolling the table over
|
||||
// a column of them popped card after card. It waits for the pointer to rest on the cell now, and a
|
||||
// scroll puts an open one away.
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, act, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import AboutCell from "./AboutCell";
|
||||
import { api } from "../../lib/api";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(api, "channelDetail").mockReturnValue(new Promise(() => {}));
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function mount() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<AboutCell text="An element of truth" channelId="UC1" />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return screen.getByText("An element of truth");
|
||||
}
|
||||
// Present in the DOM is enough: it paints once floating-ui has placed it, which a fake clock never lets happen.
|
||||
const card = () => document.querySelector('[role="tooltip"]');
|
||||
const wait = (ms: number) => act(() => vi.advanceTimersByTime(ms));
|
||||
const enter = (el: HTMLElement) =>
|
||||
act(() => {
|
||||
fireEvent.pointerEnter(el);
|
||||
fireEvent.mouseEnter(el);
|
||||
fireEvent.mouseMove(el);
|
||||
});
|
||||
|
||||
describe("AboutCell — the hover card", () => {
|
||||
it("does not open for a pointer passing over the cell", async () => {
|
||||
const cell = mount();
|
||||
enter(cell);
|
||||
await wait(300);
|
||||
expect(card()).toBeNull();
|
||||
fireEvent.mouseLeave(cell);
|
||||
await wait(600);
|
||||
expect(card()).toBeNull();
|
||||
});
|
||||
|
||||
it("opens once the pointer rests on it", async () => {
|
||||
const cell = mount();
|
||||
enter(cell);
|
||||
await wait(550);
|
||||
expect(card()).not.toBeNull();
|
||||
});
|
||||
|
||||
it("goes away when the table scrolls", async () => {
|
||||
const cell = mount();
|
||||
enter(cell);
|
||||
await wait(550);
|
||||
expect(card()).not.toBeNull();
|
||||
act(() => void window.dispatchEvent(new Event("scroll")));
|
||||
await wait(50);
|
||||
expect(card()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,9 @@ import { SURFACE } from "../../components/ui/surfaces";
|
||||
* The card keeps the platform's default hover role (`tooltip`); making this rich, interactive panel
|
||||
* properly keyboard-reachable belongs to the focus/a11y work, not to the mechanical migration.
|
||||
*/
|
||||
// How long the pointer rests on a cell before its card opens.
|
||||
const ABOUT_REST_MS = 500;
|
||||
|
||||
export default function AboutCell({ text, channelId }: { text: string | null; channelId: string }) {
|
||||
const preview = (text ?? "").trim();
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -28,8 +31,11 @@ export default function AboutCell({ text, channelId }: { text: string | null; ch
|
||||
onOpenChange: setOpen,
|
||||
mode: "hover",
|
||||
offset: 6,
|
||||
// Pop instantly like the old cell did; the close grace lets the cursor reach the card.
|
||||
hoverDelay: { open: 0, close: 160 },
|
||||
// Opens once the pointer rests on the cell, not as it crosses one: scrolling the table under a
|
||||
// still pointer, or moving down the column, used to pop card after card. The close grace lets
|
||||
// the cursor reach the card; a scroll puts an open one away.
|
||||
hoverDelay: { open: ABOUT_REST_MS, close: 160 },
|
||||
dismissOnScroll: true,
|
||||
// Cap the panel to the space between the trigger and the viewport edge (publishes --fit-max-h) so a
|
||||
// long About never runs its top off-screen — at high browser zoom the short viewport made a fixed
|
||||
// max-height overflow above the top edge, where the scroll container can't reach.
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
} from "./channelColumns";
|
||||
import { NO_TAG_SENTINEL, type ManagedChannel, type Tag } from "../../lib/api";
|
||||
import type { TFunction } from "i18next";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { formatDate } from "../../lib/format";
|
||||
|
||||
const ch = (over: Partial<ManagedChannel>): ManagedChannel => ({
|
||||
id: "c",
|
||||
@@ -24,6 +26,7 @@ const ch = (over: Partial<ManagedChannel>): ManagedChannel => ({
|
||||
description: null,
|
||||
stored_videos: 0,
|
||||
last_video_at: null,
|
||||
added_at: "2026-06-10T08:00:00+00:00",
|
||||
total_duration_seconds: 0,
|
||||
priority: 0,
|
||||
hidden: false,
|
||||
@@ -147,3 +150,39 @@ describe("buildChannelColumns — the row's end", () => {
|
||||
expect(cols[cols.length - 1]!.render(ch({ id: "UCz" }))).toBe("more:UCz");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildChannelColumns — Added", () => {
|
||||
const noop = () => {};
|
||||
const cols = buildChannelColumns({
|
||||
t: ((k: string) => k) as never,
|
||||
lang: "en",
|
||||
prioOptions: [],
|
||||
userTags: [],
|
||||
bumpPriority: noop,
|
||||
setPriority: noop,
|
||||
more: () => null,
|
||||
onView: noop,
|
||||
toggleTag: noop,
|
||||
createAndAttachTag: noop,
|
||||
onFilterByTag: noop,
|
||||
suggestionsFor: () => [],
|
||||
onAcceptSuggestion: noop,
|
||||
onDismissSuggestion: noop,
|
||||
});
|
||||
const added = cols.find((c) => c.key === "added");
|
||||
|
||||
it("follows Last upload, and sorts by when the follow reached the list", () => {
|
||||
expect(added?.header).toBe("channels.cols.added");
|
||||
const keys = cols.map((c) => c.key);
|
||||
expect(keys.indexOf("added")).toBe(keys.indexOf("lastUpload") + 1);
|
||||
expect(added?.sortable).toBe(true);
|
||||
const early = ch({ added_at: "2026-06-10T08:00:00+00:00" });
|
||||
const late = ch({ added_at: "2026-10-12T08:00:00+00:00" });
|
||||
expect(added!.sortValue!(late) as number).toBeGreaterThan(added!.sortValue!(early) as number);
|
||||
});
|
||||
|
||||
it("shows the day, not a relative time", () => {
|
||||
render(<>{added!.render(ch({ added_at: "2026-10-12T08:00:00+00:00" }))}</>);
|
||||
expect(screen.getByText(formatDate("2026-10-12T08:00:00+00:00", "en"))).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ComponentProps, ReactNode } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { NO_TAG_SENTINEL, type ManagedChannel, type Tag } from "../../lib/api";
|
||||
import { tagPickLabel, type TagList } from "../../lib/tagPicks";
|
||||
import { formatCountOrDash, formatTotalHours } from "../../lib/format";
|
||||
import { formatCountOrDash, formatDate, formatTotalHours } from "../../lib/format";
|
||||
import type { Column } from "../../components/ui/DataTable";
|
||||
import { PriorityStepper } from "../../components/ui/PriorityStepper";
|
||||
import ChannelLink from "./ChannelLink";
|
||||
@@ -204,6 +204,20 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChan
|
||||
sortValue: (c) => (c.last_video_at ? new Date(c.last_video_at).getTime() : 0),
|
||||
render: (c) => <RelativeTime at={c.last_video_at} empty="—" className="text-muted" />,
|
||||
},
|
||||
{
|
||||
key: "added",
|
||||
header: t("channels.cols.added"),
|
||||
nowrap: true,
|
||||
// The day, not a relative time: beside Last upload two "… ago" columns read as one, and when
|
||||
// a channel was added is a fixed fact. Room for "2026. okt. 12." and a sort arrow.
|
||||
width: "7.5rem",
|
||||
tabletHidden: true,
|
||||
sortable: true,
|
||||
sortValue: (c) => new Date(c.added_at).getTime(),
|
||||
render: (c) => (
|
||||
<span className="text-muted tabular-nums">{formatDate(c.added_at, lang)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "length",
|
||||
header: t("channels.cols.length"),
|
||||
|
||||
@@ -59,6 +59,7 @@ const ch = (over: Partial<ManagedChannel> = {}): ManagedChannel => ({
|
||||
description: null,
|
||||
stored_videos: 0,
|
||||
last_video_at: null,
|
||||
added_at: "2026-06-10T08:00:00+00:00",
|
||||
total_duration_seconds: 0,
|
||||
priority: 0,
|
||||
hidden: false,
|
||||
|
||||
Reference in New Issue
Block a user