refactor(channels): shared tag-mutation hook + a channel_detail tag_ids test (R8 review)

Address the two remaining review findings on the tag work:
- extract useChannelTagActions — the optimistic attach/detach + create-and-attach flow that
  the channel manager and the channel detail page had copy-pasted; the cache-shape-specific
  optimistic update is injected (manager maps a ManagedChannel[]; detail patches one
  ChannelDetail), so the two stay in sync;
- add a DB-lane test asserting GET /api/channels/{id} returns exactly this user's personal
  tag links — not another user's, not the auto/system (user_id NULL) links.
This commit is contained in:
2026-07-30 23:33:50 +02:00
parent 3352d0095c
commit f091247624
4 changed files with 132 additions and 59 deletions
+19 -32
View File
@@ -26,6 +26,7 @@ import { api, type FeedFilters, type Me, type ChannelDetail } from "../lib/api";
import type { FeedView } from "../lib/feedView";
import { channelYouTubeUrl, formatViews } from "../lib/format";
import { ChannelTagsControl } from "./ChannelTags";
import { useChannelTagActions } from "../lib/useChannelTagActions";
// A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the
// channel's videos (the catalog filtered to this channel). For an un-subscribed channel it
@@ -167,38 +168,24 @@ export default function ChannelPage({
onError: (err) => notifyYouTubeActionError(err, t("channel.fullHistoryFailed")),
});
// Personal tags, editable right here (the same control the channel manager uses). Optimistic on
// this channel's detail cache; a failed attach/detach reverts by refetching. Invalidate the manager
// list too so a tag added here shows there. createTag mirrors the manager's create-and-attach.
// Personal tags, editable right here (the same control + flow the channel manager uses). Optimistic
// on this channel's detail cache; a failed attach/detach reverts by refetching, and a success
// invalidates the manager list so a tag added here shows there too.
const tagsQuery = useQuery({ queryKey: qk.tags(), queryFn: api.tags });
const userTags = (tagsQuery.data ?? []).filter((tg) => !tg.system);
const toggleTag = (tagId: number) => {
if (!ch) return;
const hasTag = ch.tag_ids.includes(tagId);
qc.setQueryData<ChannelDetail>(qk.channel(channelId), (old) =>
old
? {
...old,
tag_ids: hasTag ? old.tag_ids.filter((x) => x !== tagId) : [...old.tag_ids, tagId],
}
: old,
);
const call = hasTag
? api.detachChannelTag(channelId, tagId)
: api.attachChannelTag(channelId, tagId);
call
.then(() => qc.invalidateQueries({ queryKey: qk.channels() }))
.catch(() => qc.invalidateQueries({ queryKey: qk.channel(channelId) }));
};
const createAndAttachTag = (name: string) => {
api
.createTag({ name })
.then((tag) => {
qc.invalidateQueries({ queryKey: qk.tags() });
toggleTag(tag.id);
})
.catch(() => notify({ level: "error", message: t("channels.tags.createFailed") }));
};
const { toggleTag, createAndAttachTag } = useChannelTagActions({
optimisticToggle: (id, tagId, attach) =>
qc.setQueryData<ChannelDetail>(qk.channel(id), (old) =>
old
? {
...old,
tag_ids: attach ? [...old.tag_ids, tagId] : old.tag_ids.filter((x) => x !== tagId),
}
: old,
),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.channels() }),
onError: (id) => qc.invalidateQueries({ queryKey: qk.channel(id) }),
});
const onUnsubscribe = async () => {
const ok = await confirm({
@@ -452,8 +439,8 @@ export default function ChannelPage({
<ChannelTagsControl
userTags={userTags}
attachedIds={ch.tag_ids}
onToggle={toggleTag}
onCreate={createAndAttachTag}
onToggle={(tagId) => toggleTag(channelId, tagId, ch.tag_ids.includes(tagId))}
onCreate={(name) => createAndAttachTag(channelId, name)}
/>
)}
</div>