fix(feed): the count pill dims while a new search's count loads
keepPreviousData held the last filter's count, and a broad word's count takes seconds: the pill showed the old number as the new search's. It now dims and is aria-busy until the new count lands, as AdminLogs does.
This commit is contained in:
@@ -0,0 +1,98 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// The count pill while a new search's count loads. keepPreviousData holds the last filter's count, and a
|
||||||
|
// broad word's count takes seconds ("a": ~3 s on the prod-sized library, E31): the pill showed the old
|
||||||
|
// number as if it were the new search's. It now dims and is aria-busy until the new count lands.
|
||||||
|
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||||
|
import { render, screen, waitFor, cleanup } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("react-i18next")>()),
|
||||||
|
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||||
|
}));
|
||||||
|
vi.mock("./wizardContext", () => ({ useWizard: () => ({ openWizard: vi.fn() }) }));
|
||||||
|
vi.mock("./VirtualFeed", () => ({ default: () => null }));
|
||||||
|
vi.mock("./SelectedViewChip", () => ({ default: () => null }));
|
||||||
|
vi.mock("./ActiveFilterChips", () => ({ default: () => null }));
|
||||||
|
|
||||||
|
import Feed from "./Feed";
|
||||||
|
import { api, DEFAULT_TAG_MODES, type FeedFilters } from "../lib/api";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
import { PageToolbarSlot } from "./ui/pageToolbarSlot";
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
const FILTERS: FeedFilters = {
|
||||||
|
tags: [],
|
||||||
|
tagModes: DEFAULT_TAG_MODES,
|
||||||
|
channelIds: [],
|
||||||
|
q: "",
|
||||||
|
sort: "newest",
|
||||||
|
scope: "my",
|
||||||
|
includeNormal: true,
|
||||||
|
includeShorts: false,
|
||||||
|
includeLive: false,
|
||||||
|
show: "unwatched",
|
||||||
|
};
|
||||||
|
|
||||||
|
// The pill lives in the toolbar, which portals into the shell's band: give it one.
|
||||||
|
const slot = document.body.appendChild(document.createElement("div"));
|
||||||
|
const feed = (q: string, qc: QueryClient): ReactElement => (
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<PageToolbarSlot.Provider value={slot}>
|
||||||
|
<Feed
|
||||||
|
filters={{ ...FILTERS, q }}
|
||||||
|
setFilters={() => {}}
|
||||||
|
view="cards"
|
||||||
|
setView={() => {}}
|
||||||
|
canRead
|
||||||
|
ytSearch=""
|
||||||
|
onYtSearch={() => {}}
|
||||||
|
onExitYtSearch={() => {}}
|
||||||
|
/>
|
||||||
|
</PageToolbarSlot.Provider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("Feed — the count while a new search's count loads", () => {
|
||||||
|
it("dims the previous count and marks it busy until the new one lands", async () => {
|
||||||
|
vi.spyOn(api, "feed").mockResolvedValue({ items: [], next_cursor: null } as never);
|
||||||
|
let settle: (v: unknown) => void = () => {};
|
||||||
|
vi.spyOn(api, "feedSummary").mockImplementation(((f: FeedFilters) =>
|
||||||
|
f.q === "kitchen"
|
||||||
|
? Promise.resolve({ count: 1937, facets: {} })
|
||||||
|
: new Promise((r) => (settle = r))) as never);
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
|
||||||
|
const pill = () => screen.getByTestId("item-count");
|
||||||
|
|
||||||
|
const view = render(feed("kitchen", qc));
|
||||||
|
await waitFor(() => expect(pill().getAttribute("title")).toBe("1,937"));
|
||||||
|
expect(pill().getAttribute("aria-busy")).toBeNull();
|
||||||
|
|
||||||
|
view.rerender(feed("a", qc));
|
||||||
|
await waitFor(() => expect(pill().getAttribute("aria-busy")).toBe("true"));
|
||||||
|
expect(pill().getAttribute("title")).toBe("1,937"); // still the old figure, now marked as such
|
||||||
|
|
||||||
|
settle({ count: 178991, facets: {} });
|
||||||
|
await waitFor(() => expect(pill().getAttribute("title")).toBe("178,991"));
|
||||||
|
expect(pill().getAttribute("aria-busy")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -710,7 +710,7 @@ export default function Feed({
|
|||||||
<div className="flex items-center gap-1.5 gap-y-2 flex-wrap">
|
<div className="flex items-center gap-1.5 gap-y-2 flex-wrap">
|
||||||
{/* The item count is the fixed leading element — a fixed-width pill so a changing number
|
{/* The item count is the fixed leading element — a fixed-width pill so a changing number
|
||||||
never shoves the controls beside it (see ItemCount). */}
|
never shoves the controls beside it (see ItemCount). */}
|
||||||
<ItemCount count={summaryQuery.data?.count} />
|
<ItemCount count={summaryQuery.data?.count} stale={summaryQuery.isPlaceholderData} />
|
||||||
<FilterFoldButton
|
<FilterFoldButton
|
||||||
fold={fold}
|
fold={fold}
|
||||||
label={t("sidebar.filters")}
|
label={t("sidebar.filters")}
|
||||||
|
|||||||
@@ -62,4 +62,12 @@ describe("ItemCount", () => {
|
|||||||
expect(text()).toBe(""); // the Layers icon carries no text
|
expect(text()).toBe(""); // the Layers icon carries no text
|
||||||
expect(screen.getByTestId("item-count").getAttribute("title")).toBeNull();
|
expect(screen.getByTestId("item-count").getAttribute("title")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("dims the previous filter's count and marks it busy while the new one loads", () => {
|
||||||
|
const { rerender } = render(<ItemCount count={5} />);
|
||||||
|
expect(screen.getByTestId("item-count").getAttribute("aria-busy")).toBeNull();
|
||||||
|
rerender(<ItemCount count={5} stale />);
|
||||||
|
expect(screen.getByTestId("item-count").getAttribute("aria-busy")).toBe("true");
|
||||||
|
expect(screen.getByText("5 items").className).toContain("opacity-60");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import clsx from "clsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Layers } from "lucide-react";
|
import { Layers } from "lucide-react";
|
||||||
import { formatNumber } from "../../lib/format";
|
import { formatNumber } from "../../lib/format";
|
||||||
@@ -12,8 +13,10 @@ const OVERFLOW_AT = 1_000_000;
|
|||||||
// changing number must never shove the controls beside it, so the box is sized to the widest value
|
// changing number must never shove the controls beside it, so the box is sized to the widest value
|
||||||
// ("999,999 items") and the content is centred within it. A count of a million or more collapses to
|
// ("999,999 items") and the content is centred within it. A count of a million or more collapses to
|
||||||
// "1M+", with the exact figure in the title tooltip. Deliberately generic ("item(s)", dynamic plural)
|
// "1M+", with the exact figure in the title tooltip. Deliberately generic ("item(s)", dynamic plural)
|
||||||
// so any surface can reuse it for whatever it counts.
|
// so any surface can reuse it for whatever it counts. `stale`: the count is the previous filter's,
|
||||||
export default function ItemCount({ count }: { count?: number }) {
|
// kept while the new one loads (keepPreviousData) — dimmed and aria-busy, as AdminLogs dims its lines,
|
||||||
|
// so a broad search's slow count ("a": ~3 s) never reads as the new result.
|
||||||
|
export default function ItemCount({ count, stale = false }: { count?: number; stale?: boolean }) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const lang = i18n.language;
|
const lang = i18n.language;
|
||||||
// `count` is undefined only on a first, un-cached load — keep the fixed box (nothing shifts), just
|
// `count` is undefined only on a first, un-cached load — keep the fixed box (nothing shifts), just
|
||||||
@@ -29,10 +32,13 @@ export default function ItemCount({ count }: { count?: number }) {
|
|||||||
<span
|
<span
|
||||||
data-testid="item-count"
|
data-testid="item-count"
|
||||||
title={count == null ? undefined : formatNumber(count, lang)}
|
title={count == null ? undefined : formatNumber(count, lang)}
|
||||||
|
aria-busy={stale || undefined}
|
||||||
className="inline-flex h-7 min-w-[9rem] shrink-0 items-center justify-center gap-1.5 rounded-full bg-fg/8 px-3 text-sm whitespace-nowrap text-muted"
|
className="inline-flex h-7 min-w-[9rem] shrink-0 items-center justify-center gap-1.5 rounded-full bg-fg/8 px-3 text-sm whitespace-nowrap text-muted"
|
||||||
>
|
>
|
||||||
<Layers className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
|
<Layers className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
|
||||||
<span className="tabular-nums">{label}</span>
|
<span className={clsx("tabular-nums transition-opacity", stale && "opacity-60")}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user