refactor(ui): reuse the store, media-query and deleted-tag rules the review found rebuilt, and name a loading pick

E32 review round 1 (F3–F6).

- Channels' open-row store and the chat dock use lib/store's createStore instead of a hand-rolled
  listener set (the chat dock gets a test of its own).
- The Plex backdrop reads prefers-reduced-motion through useMediaQuery; the query string is one
  constant (REDUCED_MOTION) for the hook and the carousel's one-off read.
- A link-restored collection filter showed its raw rating_key in the picker while the titles
  loaded: usePlexCollectionTitles now says it is pending, the picker draws a placeholder
  (labelsPending, as the channel picker does) and so does the chip row.
- The "deleted tag" rule was written twice with two i18n keys and had drifted: the channel manager
  showed NO chip for a pick while the tag list loaded, or ever after a failed load, while the pick
  still narrowed the table. One rule now (lib/tagPicks), one key (common.deletedTag); the manager's
  row shows every pick, as a placeholder while loading and as its id after a failure.
This commit is contained in:
2026-10-04 02:29:03 +02:00
parent e09d613b04
commit e5b155ada9
24 changed files with 245 additions and 129 deletions
+5 -20
View File
@@ -9,6 +9,7 @@ import {
} from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { createStore, type Store } from "../lib/store";
import { qk } from "../lib/queryKeys";
import { Ban, Eye, EyeOff, History, LayoutGrid, Pencil, RefreshCw, Table, X } from "lucide-react";
import ExcludedChannelList from "./ExcludedChannelList";
@@ -91,24 +92,8 @@ const channelKey = (c: ManagedChannel) => c.id;
// Which channel's row menu is open, for its "…" (`aria-expanded`, and staying shown while open). A
// tiny store rather than a context: a context change walks the whole table to find its readers (~20
// ms on 400 rows, each open and close), while a store calls its subscribers and re-renders only the
// two buttons whose answer changed.
function createOpenRowStore() {
let open: string | null = null;
const listeners = new Set<() => void>();
return {
get: () => open,
set: (next: string | null) => {
if (next === open) return;
open = next;
listeners.forEach((l) => l());
},
subscribe: (l: () => void) => {
listeners.add(l);
return () => void listeners.delete(l);
},
};
}
type OpenRowStore = ReturnType<typeof createOpenRowStore>;
// two buttons whose answer changed (each reads a boolean, so a change elsewhere renders nothing).
type OpenRowStore = Store<string | null>;
function RowMoreButton({
id,
@@ -349,7 +334,7 @@ export default function Channels() {
// stays one line and the trailing "…" actions can't wrap.
const tagChips = tagFilterChips({
userTags,
allTags,
tags: tagsQuery,
picked: tagFilter,
noTagOn,
toggle: toggleTagFilter,
@@ -397,7 +382,7 @@ export default function Channels() {
// reposition, and so re-rendered the whole table — ~0.8 s to open the menu on 400 rows.
const { buttonProps: rowButtonProps, triggerProps: rowTriggerProps } = rowMenu;
const openRowId = rowMenu.open ? (rowMenu.target?.id ?? null) : null;
const [openRow] = useState(createOpenRowStore);
const [openRow] = useState(() => createStore<string | null>(null));
useEffect(() => openRow.set(openRowId), [openRow, openRowId]);
const channelActions = useChannelActions({
onView,
+2 -1
View File
@@ -439,7 +439,7 @@ function CollectionPicker({
// …but a selection restored from a LINK has ids and no titles, and the list above is only fetched
// once the picker is opened — so the token rendered the raw rating_key until then. Resolve just the
// selected ids (see usePlexCollectionTitles).
const resolved = usePlexCollectionTitles(values, titles);
const { titles: resolved, pending } = usePlexCollectionTitles(values, titles);
const options: ComboboxOption[] = (q.data?.collections ?? []).map((c) => ({
value: c.id,
label: c.title,
@@ -459,6 +459,7 @@ function CollectionPicker({
options={options}
selected={values}
selectedLabels={resolved}
labelsPending={pending}
query={search}
onQueryChange={setSearch}
onOpenChange={setOpen}
+16 -3
View File
@@ -7,6 +7,7 @@ import { chipClass } from "./ui/chipStyles";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { SURFACE } from "./ui/surfaces";
import { ChipLabelPending } from "./ui/Skeleton";
// A single-line row of toggle filter chips that collapses its overflow into a trailing "+N" pill whose
// popover holds the rest (still toggleable) — the same compression the feed's applied-filter row uses
@@ -18,14 +19,22 @@ import { SURFACE } from "./ui/surfaces";
export interface FilterChip {
key: string;
label: string;
/** The name is still on its way: the chip draws a placeholder, not `label` (a raw id). */
pending?: boolean;
active: boolean;
onToggle: () => void;
}
// A chip's text — the visible chip and the hidden measuring mirror both draw it, so the mirror
// measures what shows (ActiveFilterChips' ChipText, for this row's chips).
function ChipText({ chip }: { chip: FilterChip }) {
return chip.pending ? <ChipLabelPending value={chip.label} /> : <>{chip.label}</>;
}
function TagChip({ chip }: { chip: FilterChip }) {
return (
<Chip onClick={chip.onToggle} pressed={chip.active}>
{chip.label}
<ChipText chip={chip} />
</Chip>
);
}
@@ -43,7 +52,9 @@ export default function TagFilterChips({
const pillRef = useRef<HTMLSpanElement | null>(null);
// Re-fit when the set, the labels, OR the pressed state change (the pressed paint can change a chip's
// width via the `.pill` recipe, so an aria-pressed flip must re-measure too).
const key = chips.map((c) => `${c.key}:${c.label}:${c.active ? 1 : 0}`).join("|");
const key = chips
.map((c) => `${c.key}:${c.label}:${c.active ? 1 : 0}${c.pending ? "…" : ""}`)
.join("|");
const visible = useChipOverflowFit<number>({
rowRef,
@@ -101,7 +112,9 @@ export default function TagFilterChips({
}}
className="inline-flex"
>
<span className={chipClass()}>{c.label}</span>
<span className={chipClass()}>
<ChipText chip={c} />
</span>
</span>
))}
<span ref={pillRef} className={chipClass()}>
@@ -81,10 +81,15 @@ describe("tagsColumnTest (item B — 'No tag')", () => {
describe("tagFilterChips — a pick whose tag is gone (E32 S2)", () => {
const mine = { id: 1, name: "Music", system: false } as Tag;
const t = ((k: string) => k) as unknown as TFunction;
const chips = (allTags: Tag[] | undefined, picked: string[]) =>
type State = "loaded" | "loading" | "failed";
const chips = (state: State, picked: string[]) =>
tagFilterChips({
userTags: allTags ? [mine] : [],
allTags,
userTags: state === "loaded" ? [mine] : [],
tags: {
data: state === "loaded" ? [mine] : undefined,
isSuccess: state === "loaded",
isPending: state === "loading",
},
picked,
noTagOn: false,
toggle: () => {},
@@ -93,15 +98,27 @@ describe("tagFilterChips — a pick whose tag is gone (E32 S2)", () => {
});
it("stays a visible, active chip instead of an empty table with no reason", () => {
expect(chips([mine], ["1", "77"]).map((c) => [c.key, c.label, c.active])).toEqual([
["t77", "channels.tags.deleted", true], // first: the "+N" overflow must not fold it away
expect(chips("loaded", ["1", "77"]).map((c) => [c.key, c.label, c.active])).toEqual([
["t77", "common.deletedTag", true], // first: the "+N" overflow must not fold it away
["t1", "Music", true],
[NO_TAG_SENTINEL, "channels.tags.noTag", false],
]);
});
it("is not called deleted before the tag list has loaded", () => {
expect(chips(undefined, ["77"]).map((c) => c.key)).toEqual([NO_TAG_SENTINEL]);
// E32 review: before, a pick had NO chip at all until the list loaded — or ever, after a failed
// load — while it still narrowed the table.
it("is a placeholder while the tag list loads, never called deleted", () => {
expect(chips("loading", ["77"]).map((c) => [c.key, c.label, c.pending])).toEqual([
["t77", "77", true],
[NO_TAG_SENTINEL, "channels.tags.noTag", undefined],
]);
});
it("shows the id after a failed load, still a visible, removable chip", () => {
expect(chips("failed", ["77"]).map((c) => [c.key, c.label, c.pending, c.active])).toEqual([
["t77", "77", false, true],
[NO_TAG_SENTINEL, "channels.tags.noTag", undefined, false],
]);
});
});
+13 -9
View File
@@ -1,6 +1,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 type { Column } from "./ui/DataTable";
import { PriorityStepper } from "./ui/PriorityStepper";
@@ -50,25 +51,28 @@ export function tagsColumnTest(c: ManagedChannel, values: string[]): boolean {
return values.some((v) => c.tag_ids.includes(Number(v)));
}
/** The manager's tag-filter chip row (the Tags column filter's values): any picked id whose tag no
* longer exists, then each user tag, then the "No tag" sentinel. A stored pick can outlive its tag
* (deleted here or on another device) and still narrows the table to nothing, so it stays a visible,
* removable chip — FIRST, so the row's "+N" overflow never folds the reason away (E32 S2). */
/** The manager's tag-filter chip row (the Tags column filter's values): any picked id that is not one
* of the user's tags, then each user tag, then the "No tag" sentinel. A stored pick can outlive its
* tag (deleted here or on another device) and still narrows the table to nothing, so it stays a
* visible, removable chip — FIRST, so the row's "+N" overflow never folds the reason away (E32 S2) —
* and so does every pick while the tag list loads or after it failed, named by lib/tagPicks' rule
* (a placeholder, then the name, "Deleted tag" or the id), not hidden (E32 review). */
export function tagFilterChips(o: {
userTags: Tag[];
allTags: Tag[] | undefined;
tags: TagList;
picked: string[];
noTagOn: boolean;
toggle: (tagId: number) => void;
toggleNoTag: () => void;
t: TFunction;
}): FilterChip[] {
const known = new Set((o.allTags ?? []).map((tg) => String(tg.id)));
const gone = o.allTags ? o.picked.filter((id) => id !== NO_TAG_SENTINEL && !known.has(id)) : [];
const mine = new Set(o.userTags.map((tg) => String(tg.id)));
const others = o.picked.filter((id) => id !== NO_TAG_SENTINEL && !mine.has(id));
return [
...gone.map((id) => ({
...others.map((id) => ({
key: `t${id}`,
label: o.t("channels.tags.deleted"),
label: tagPickLabel(Number(id), o.tags, o.t),
pending: o.tags.isPending,
active: true,
onToggle: () => o.toggle(Number(id)),
})),
@@ -39,7 +39,7 @@ describe("a tag filter's chip names", () => {
const chip = (id: number) => result.current.find((f) => f.key === `tag:${id}`)!;
expect(chip(99).pending).toBe(true);
await waitFor(() => expect(chip(1)).toMatchObject({ label: "Music", pending: false }));
expect(chip(99)).toMatchObject({ label: "feed.chips.deletedTag", pending: false });
expect(chip(99)).toMatchObject({ label: "common.deletedTag", pending: false });
});
it("fall back to the id when the tag list fails, never claiming the tag was deleted", async () => {
+2 -1
View File
@@ -6,6 +6,7 @@ import { useScrollEdges } from "../../lib/useScrollEdges";
import { useListNav } from "../../lib/listNav";
import { Button } from "./Button";
import { IconButton } from "./IconButton";
import { REDUCED_MOTION } from "../../lib/useMediaQuery";
// Shared horizontal rail — the one place the app's "row of cards you scroll sideways" lives, replacing
// the hand-rolled `flex gap overflow-x-auto` strips (Cast / Related / collections) and the future
@@ -19,7 +20,7 @@ function scrollBehavior(): ScrollBehavior {
const reduce =
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
window.matchMedia(REDUCED_MOTION).matches;
return reduce ? "auto" : "smooth";
}
@@ -85,6 +85,15 @@ describe("usePlexActiveFilters", () => {
expect(byKey["collection:A"]).toBe("Marvel");
});
it("marks a link-supplied collection chip pending while its title loads, not the known one", () => {
qc.removeQueries({ queryKey: qk.plexCollections("titles", "B") });
vi.spyOn(api, "plexCollections").mockReturnValue(new Promise(() => {}) as never);
const { result } = renderHook(() => usePlexActiveFilters(), { wrapper });
const pending = Object.fromEntries(result.current.map((c) => [c.key, c.pending]));
expect(pending["collection:B"]).toBe(true);
expect(pending["collection:A"]).toBe(false);
});
it("removing the last collection chip prunes its title and collapses collectionTitles to undefined", () => {
setFilters.mockClear();
const { result } = renderHook(() => usePlexActiveFilters(), { wrapper });
+40 -19
View File
@@ -23,8 +23,16 @@ export function usePlexActiveFilters(): ActiveFilter[] {
const patch = (p: Partial<PlexFilters>) => setFilters({ ...filters, ...p });
// A link opened cold knows the collection ids but not their names — resolve the missing ones for
// display (see usePlexCollectionTitles for why they are NOT written back into the filter state).
const collectionTitles = usePlexCollectionTitles(filters.collections, filters.collectionTitles);
return buildPlexActiveFilters(filters, show, patch, setShow, t, collectionTitles);
const collections = usePlexCollectionTitles(filters.collections, filters.collectionTitles);
return buildPlexActiveFilters(
filters,
show,
patch,
setShow,
t,
collections.titles,
collections.pending,
);
}
// The pure builder behind the hook: turns a Plex filter set + watch-state into the source-agnostic
@@ -39,6 +47,8 @@ export function buildPlexActiveFilters(
/** Collection titles for the chip labels, resolved beyond what the filter state carries. Omitted by
* the saved-views manager, which describes a stored view and has only the captured titles. */
collectionTitles?: Record<string, string>,
/** The missing titles are still loading: their chips show a placeholder, not the generic word. */
collectionsPending = false,
): ActiveFilter[] {
const out: ActiveFilter[] = [];
@@ -134,23 +144,7 @@ export function buildPlexActiveFilters(
remove: () => patch({ keywords: filters.keywords.filter((x) => x !== k) }),
});
}
for (const c of filters.collections) {
out.push({
key: `collection:${c}`,
label: collectionTitles?.[c] ?? filters.collectionTitles?.[c] ?? t("plex.filter.collection"),
// Prune the title too — collectionTitles is hashed into the plex query keys, so a stale entry
// splits the cache from the logically-identical no-collection state (mirrors CollectionPicker).
// Collapse an empty map back to undefined so the last-removed state key-matches the pristine
// one (an empty `{}` still hashes differently from an absent key).
remove: () => {
const { [c]: _drop, ...restTitles } = filters.collectionTitles ?? {};
patch({
collections: filters.collections.filter((x) => x !== c),
collectionTitles: Object.keys(restTitles).length ? restTitles : undefined,
});
},
});
}
out.push(...collectionChips(filters, patch, t, collectionTitles, collectionsPending));
if (show !== "all") {
out.push({
key: "show",
@@ -171,3 +165,30 @@ export function usePlexClearFilters(): () => void {
setSort(PLEX_SORT_FALLBACK);
};
}
// One chip per selected collection, named by the resolved titles (a placeholder while a link's titles
// load), else by the ones the state carries, else the generic word.
function collectionChips(
filters: PlexFilters,
patch: (p: Partial<PlexFilters>) => void,
t: TFn,
collectionTitles: Record<string, string> | undefined,
pending: boolean,
): ActiveFilter[] {
return filters.collections.map((c) => ({
key: `collection:${c}`,
label: collectionTitles?.[c] ?? filters.collectionTitles?.[c] ?? t("plex.filter.collection"),
pending: pending && collectionTitles?.[c] === undefined,
// Prune the title too — collectionTitles is hashed into the plex query keys, so a stale entry
// splits the cache from the logically-identical no-collection state (mirrors CollectionPicker).
// Collapse an empty map back to undefined so the last-removed state key-matches the pristine
// one (an empty `{}` still hashes differently from an absent key).
remove: () => {
const { [c]: _drop, ...restTitles } = filters.collectionTitles ?? {};
patch({
collections: filters.collections.filter((x) => x !== c),
collectionTitles: Object.keys(restTitles).length ? restTitles : undefined,
});
},
}));
}